From 129dc1496ec9635fb3286631f95e1bc63163a667 Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 6 Feb 2026 13:29:21 +0545 Subject: [PATCH 01/22] feat: add package for fastify worker --- packages/worker/.gitignore | 2 ++ packages/worker/README.md | 1 + packages/worker/eslint.config.js | 3 ++ packages/worker/package.json | 56 ++++++++++++++++++++++++++++++ packages/worker/src/index.ts | 11 ++++++ packages/worker/src/types/index.ts | 5 +++ packages/worker/tsconfig.json | 11 ++++++ packages/worker/vite.config.ts | 54 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 41 ++++++++++++++++++++++ 9 files changed, 184 insertions(+) create mode 100644 packages/worker/.gitignore create mode 100644 packages/worker/README.md create mode 100644 packages/worker/eslint.config.js create mode 100644 packages/worker/package.json create mode 100644 packages/worker/src/index.ts create mode 100644 packages/worker/src/types/index.ts create mode 100644 packages/worker/tsconfig.json create mode 100644 packages/worker/vite.config.ts diff --git a/packages/worker/.gitignore b/packages/worker/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/packages/worker/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/worker/README.md b/packages/worker/README.md new file mode 100644 index 000000000..17b34f39f --- /dev/null +++ b/packages/worker/README.md @@ -0,0 +1 @@ +# @prefabs.tech/fastify-worker diff --git a/packages/worker/eslint.config.js b/packages/worker/eslint.config.js new file mode 100644 index 000000000..48a1291a4 --- /dev/null +++ b/packages/worker/eslint.config.js @@ -0,0 +1,3 @@ +import fastifyConfig from "@prefabs.tech/eslint-config/fastify.js"; + +export default fastifyConfig; diff --git a/packages/worker/package.json b/packages/worker/package.json new file mode 100644 index 000000000..ae74b777c --- /dev/null +++ b/packages/worker/package.json @@ -0,0 +1,56 @@ +{ + "name": "@prefabs.tech/fastify-worker", + "version": "0.93.4", + "description": "Fastify worker plugin", + "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/worker#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/prefabs-tech/fastify.git", + "directory": "packages/worker" + }, + "license": "MIT", + "exports": { + ".": { + "import": "./dist/prefabs-tech-fastify-worker.js", + "require": "./dist/prefabs-tech-fastify-worker.cjs" + } + }, + "main": "./dist/prefabs-tech-fastify-worker.cjs", + "module": "./dist/prefabs-tech-fastify-worker.js", + "types": "./dist/types/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "sort-package": "npx sort-package-json", + "typecheck": "tsc --noEmit -p tsconfig.json --composite false" + }, + "dependencies": { + "zod": "3.25.76" + }, + "devDependencies": { + "@prefabs.tech/eslint-config": "0.4.0", + "@prefabs.tech/tsconfig": "0.2.0", + "@prefabs.tech/fastify-config": "0.93.4", + "eslint": "9.39.2", + "fastify": "5.6.1", + "fastify-plugin": "5.1.0", + "prettier": "3.6.2", + "supertokens-node": "14.1.4", + "typescript": "5.9.3", + "vite": "6.4.1", + "vitest": "3.2.4" + }, + "peerDependencies": { + "@prefabs.tech/fastify-config": "0.93.4", + "fastify": ">=5.2.1", + "fastify-plugin": ">=5.0.1", + "supertokens-node": ">=14.1.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts new file mode 100644 index 000000000..c4a5476e6 --- /dev/null +++ b/packages/worker/src/index.ts @@ -0,0 +1,11 @@ +import "@prefabs.tech/fastify-config"; + +import { WorkerConfig } from "./types"; + +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + worker: WorkerConfig; + } +} + +export type { WorkerConfig } from "./types"; diff --git a/packages/worker/src/types/index.ts b/packages/worker/src/types/index.ts new file mode 100644 index 000000000..40e599874 --- /dev/null +++ b/packages/worker/src/types/index.ts @@ -0,0 +1,5 @@ +export interface WorkerConfig { + cronJobs?: { + expression: string; + }[]; +} diff --git a/packages/worker/tsconfig.json b/packages/worker/tsconfig.json new file mode 100644 index 000000000..380daccb6 --- /dev/null +++ b/packages/worker/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@prefabs.tech/tsconfig/fastify.json", + "exclude": [ + "src/**/__test__/**/*", + ], + "compilerOptions": { + "baseUrl": "./", + "outDir": "dist", + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/worker/vite.config.ts b/packages/worker/vite.config.ts new file mode 100644 index 000000000..cad5f275e --- /dev/null +++ b/packages/worker/vite.config.ts @@ -0,0 +1,54 @@ +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineConfig, loadEnv } from "vite"; + +import { dependencies, peerDependencies } from "./package.json"; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => { + process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; + + return { + build: { + lib: { + entry: resolve(dirname(fileURLToPath(import.meta.url)), "src/index.ts"), + fileName: "prefabs-tech-fastify-worker", + formats: ["cjs", "es"], + name: "PrefabsTechFastifyWorker", + }, + rollupOptions: { + external: [ + ...Object.keys(dependencies), + ...Object.keys(peerDependencies), + ], + output: { + exports: "named", + globals: { + "@prefabs.tech/fastify-error-handler": + "PrefabsTechFastifyErrorHandler", + "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", + "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", + fastify: "Fastify", + "fastify-plugin": "FastifyPlugin", + mercurius: "mercurius", + slonik: "Slonik", + zod: "zod", + }, + }, + }, + target: "es2022", + }, + resolve: { + alias: { + "@/": new URL("src/", import.meta.url).pathname, + }, + }, + test: { + coverage: { + provider: "istanbul", + reporter: ["text", "json", "html"], + }, + }, + }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a403f6a62..b0a8201af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,6 +310,46 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + packages/processor: + dependencies: + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@prefabs.tech/eslint-config': + specifier: 0.4.0 + version: 0.4.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2)(typescript@5.9.3) + '@prefabs.tech/fastify-config': + specifier: 0.93.4 + version: link:../config + '@prefabs.tech/tsconfig': + specifier: 0.2.0 + version: 0.2.0(@types/node@24.10.0) + eslint: + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) + fastify: + specifier: 5.6.1 + version: 5.6.1 + fastify-plugin: + specifier: 5.1.0 + version: 5.1.0 + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertokens-node: + specifier: 14.1.4 + version: 14.1.4 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 6.4.1 + version: 6.4.1(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + packages/s3: dependencies: '@aws-sdk/client-s3': @@ -5169,6 +5209,7 @@ packages: scmp@2.1.0: resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} + deprecated: Just use Node.js's crypto.timingSafeEqual() secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} From cb915fffd99c8dcb97a88cc9a0ba4c4e3cc169fd Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 6 Feb 2026 13:32:33 +0545 Subject: [PATCH 02/22] chore: add package depedencies --- pnpm-lock.yaml | 80 +++++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0a8201af..54db3f40c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,46 +310,6 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) - packages/processor: - dependencies: - zod: - specifier: 3.25.76 - version: 3.25.76 - devDependencies: - '@prefabs.tech/eslint-config': - specifier: 0.4.0 - version: 0.4.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2)(typescript@5.9.3) - '@prefabs.tech/fastify-config': - specifier: 0.93.4 - version: link:../config - '@prefabs.tech/tsconfig': - specifier: 0.2.0 - version: 0.2.0(@types/node@24.10.0) - eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) - fastify: - specifier: 5.6.1 - version: 5.6.1 - fastify-plugin: - specifier: 5.1.0 - version: 5.1.0 - prettier: - specifier: 3.6.2 - version: 3.6.2 - supertokens-node: - specifier: 14.1.4 - version: 14.1.4 - typescript: - specifier: 5.9.3 - version: 5.9.3 - vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) - vitest: - specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) - packages/s3: dependencies: '@aws-sdk/client-s3': @@ -636,6 +596,46 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/worker: + dependencies: + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@prefabs.tech/eslint-config': + specifier: 0.4.0 + version: 0.4.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2)(typescript@5.9.3) + '@prefabs.tech/fastify-config': + specifier: 0.93.4 + version: link:../config + '@prefabs.tech/tsconfig': + specifier: 0.2.0 + version: 0.2.0(@types/node@24.10.0) + eslint: + specifier: 9.39.2 + version: 9.39.2(jiti@2.6.1) + fastify: + specifier: 5.6.1 + version: 5.6.1 + fastify-plugin: + specifier: 5.1.0 + version: 5.1.0 + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertokens-node: + specifier: 14.1.4 + version: 14.1.4 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 6.4.1 + version: 6.4.1(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@24.10.0)(jiti@2.6.1)(yaml@2.8.1) + packages: '@actions/core@1.11.1': From aafddd9e13fc32ded730757fafb30a811ac9ed1e Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 6 Feb 2026 15:53:28 +0545 Subject: [PATCH 03/22] feat: add cron scheduling support --- packages/worker/package.json | 3 ++- packages/worker/src/cron/index.ts | 1 + packages/worker/src/cron/setup.ts | 17 +++++++++++++++++ packages/worker/src/plugin.ts | 22 ++++++++++++++++++++++ packages/worker/src/types/cron.ts | 7 +++++++ packages/worker/src/types/index.ts | 6 +++--- pnpm-lock.yaml | 14 +++++++++++++- 7 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 packages/worker/src/cron/index.ts create mode 100644 packages/worker/src/cron/setup.ts create mode 100644 packages/worker/src/plugin.ts create mode 100644 packages/worker/src/types/cron.ts diff --git a/packages/worker/package.json b/packages/worker/package.json index ae74b777c..0376c1f2c 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -29,12 +29,13 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { + "node-cron": "^4.2.1", "zod": "3.25.76" }, "devDependencies": { "@prefabs.tech/eslint-config": "0.4.0", - "@prefabs.tech/tsconfig": "0.2.0", "@prefabs.tech/fastify-config": "0.93.4", + "@prefabs.tech/tsconfig": "0.2.0", "eslint": "9.39.2", "fastify": "5.6.1", "fastify-plugin": "5.1.0", diff --git a/packages/worker/src/cron/index.ts b/packages/worker/src/cron/index.ts new file mode 100644 index 000000000..dcdc3e6c7 --- /dev/null +++ b/packages/worker/src/cron/index.ts @@ -0,0 +1 @@ +export * from "./setup"; diff --git a/packages/worker/src/cron/setup.ts b/packages/worker/src/cron/setup.ts new file mode 100644 index 000000000..ff1a67dfe --- /dev/null +++ b/packages/worker/src/cron/setup.ts @@ -0,0 +1,17 @@ +import cron from "node-cron"; + +import { WorkerConfig } from "src/types"; + +const setupCronJobs = (config: WorkerConfig) => { + if (!config.cronJobs || config.cronJobs.length === 0) { + return; + } + + const { cronJobs } = config; + + for (const job of cronJobs) { + cron.schedule(job.expression, job.task, job.options); + } +}; + +export default setupCronJobs; diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts new file mode 100644 index 000000000..71909cf03 --- /dev/null +++ b/packages/worker/src/plugin.ts @@ -0,0 +1,22 @@ +import { FastifyInstance } from "fastify"; +import fastifyPlugin from "fastify-plugin"; + +import setupCronJobs from "./cron/setup"; + +const plugin = async (fastify: FastifyInstance) => { + const { config, log } = fastify; + + if (!config.worker) { + log.warn("Worker configuration is missing. Skipping plugin registration"); + + return; + } + + log.info("Registering worker plugin"); + + if (config.worker.cronJobs) { + setupCronJobs(config.worker); + } +}; + +export default fastifyPlugin(plugin); diff --git a/packages/worker/src/types/cron.ts b/packages/worker/src/types/cron.ts new file mode 100644 index 000000000..c0d477013 --- /dev/null +++ b/packages/worker/src/types/cron.ts @@ -0,0 +1,7 @@ +import { TaskOptions } from "node-cron"; + +export interface CronJob { + expression: string; + task: () => Promise; + options?: TaskOptions; +} diff --git a/packages/worker/src/types/index.ts b/packages/worker/src/types/index.ts index 40e599874..e693618a1 100644 --- a/packages/worker/src/types/index.ts +++ b/packages/worker/src/types/index.ts @@ -1,5 +1,5 @@ +import { CronJob } from "./cron"; + export interface WorkerConfig { - cronJobs?: { - expression: string; - }[]; + cronJobs?: CronJob[]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54db3f40c..0601e3d36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -598,6 +598,9 @@ importers: packages/worker: dependencies: + node-cron: + specifier: ^4.2.1 + version: 4.2.1 zod: specifier: 3.25.76 version: 3.25.76 @@ -716,6 +719,7 @@ packages: '@aws-sdk/credential-provider-web-identity@3.917.0': resolution: {integrity: sha512-pZncQhFbwW04pB0jcD5OFv3x2gAddDYCVxyJVixgyhSw7bKCYxqu6ramfq1NxyVpmm+qsw+ijwi/3cCmhUHF/A==} engines: {node: '>=18.0.0'} + deprecated: This version contains a compilation TypeScript error https://github.com/aws/aws-sdk-js-v3/issues/7457 - please use @aws-sdk/credential-providers@3.918.0 or higher '@aws-sdk/lib-storage@3.917.0': resolution: {integrity: sha512-Z8mRzfP6PgUoybJHx/tH40aop42yeh66cW7wLM8R88egB2UYQ0IgxDoRynqlLi5uceI21wss/CIDkhzO9p1cOg==} @@ -3449,16 +3453,18 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -4447,6 +4453,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -11116,6 +11126,8 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-cron@4.2.1: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 From 45520ae25b2c909a35a8d9341caaf9251ef7a47c Mon Sep 17 00:00:00 2001 From: sudeep Date: Tue, 17 Feb 2026 13:37:35 +0545 Subject: [PATCH 04/22] feat: define queue config --- packages/worker/package.json | 2 + packages/worker/src/enum/index.ts | 4 + packages/worker/src/types/index.ts | 2 + packages/worker/src/types/queue.ts | 20 + pnpm-lock.yaml | 1046 +++++++++++++++++++++++++++- 5 files changed, 1059 insertions(+), 15 deletions(-) create mode 100644 packages/worker/src/enum/index.ts create mode 100644 packages/worker/src/types/queue.ts diff --git a/packages/worker/package.json b/packages/worker/package.json index 0376c1f2c..9a9a8b496 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -29,6 +29,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { + "@aws-sdk/client-sqs": "3.991.0", + "bullmq": "5.69.3", "node-cron": "^4.2.1", "zod": "3.25.76" }, diff --git a/packages/worker/src/enum/index.ts b/packages/worker/src/enum/index.ts new file mode 100644 index 000000000..eeee861ce --- /dev/null +++ b/packages/worker/src/enum/index.ts @@ -0,0 +1,4 @@ +export enum QueueProvider { + SQS = "sqs", + BULLMQ = "bullmq", +} diff --git a/packages/worker/src/types/index.ts b/packages/worker/src/types/index.ts index e693618a1..982a656c5 100644 --- a/packages/worker/src/types/index.ts +++ b/packages/worker/src/types/index.ts @@ -1,5 +1,7 @@ import { CronJob } from "./cron"; +import { QueueConfig } from "./queue"; export interface WorkerConfig { cronJobs?: CronJob[]; + queues?: QueueConfig[]; } diff --git a/packages/worker/src/types/queue.ts b/packages/worker/src/types/queue.ts new file mode 100644 index 000000000..691098678 --- /dev/null +++ b/packages/worker/src/types/queue.ts @@ -0,0 +1,20 @@ +import { RedisOptions } from "bullmq"; + +import { QueueProvider } from "../enum"; + +export interface QueueConfig { + bullmqConfig?: { + connection: RedisOptions; + defaultJobOptions?: { + attempts?: number; + backoff?: { + type: string; + delay: number; + }; + removeOnComplete?: boolean | number; + removeOnFail?: boolean | number; + }; + }; + name: string; + provider: QueueProvider; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0601e3d36..d23376174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -598,6 +598,12 @@ importers: packages/worker: dependencies: + '@aws-sdk/client-sqs': + specifier: 3.991.0 + version: 3.991.0 + bullmq: + specifier: 5.69.3 + version: 5.69.3 node-cron: specifier: ^4.2.1 version: 4.2.1 @@ -684,43 +690,87 @@ packages: resolution: {integrity: sha512-6koN6wERmY5mTgK/yQ8sPH72jrHBD4BiYKhKHGg+hK5yn700iK5keoWK60CfQM4JJkhx49cHraO4dSW//3WiyA==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sqs@3.991.0': + resolution: {integrity: sha512-7apQczqvynhNt4BRyMge+CuMLzQxSr8aj1DrKIk+YN0Qd4phiq8XGWDiclVEAyKfg7JUuYK6YIWoUYl3QdIkNg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-sso@3.916.0': resolution: {integrity: sha512-Eu4PtEUL1MyRvboQnoq5YKg0Z9vAni3ccebykJy615xokVZUdA3di2YxHM/hykDQX7lcUC62q9fVIvh0+UNk/w==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sso@3.990.0': + resolution: {integrity: sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.916.0': resolution: {integrity: sha512-1JHE5s6MD5PKGovmx/F1e01hUbds/1y3X8rD+Gvi/gWVfdg5noO7ZCerpRsWgfzgvCMZC9VicopBqNHCKLykZA==} engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.973.10': + resolution: {integrity: sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.916.0': resolution: {integrity: sha512-3gDeqOXcBRXGHScc6xb7358Lyf64NRG2P08g6Bu5mv1Vbg9PKDyCAZvhKLkG7hkdfAM8Yc6UJNhbFxr1ud/tCQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-env@3.972.8': + resolution: {integrity: sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.916.0': resolution: {integrity: sha512-NmooA5Z4/kPFJdsyoJgDxuqXC1C6oPMmreJjbOPqcwo6E/h2jxaG8utlQFgXe5F9FeJsMx668dtxVxSYnAAqHQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-http@3.972.10': + resolution: {integrity: sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.917.0': resolution: {integrity: sha512-rvQ0QamLySRq+Okc0ZqFHZ3Fbvj3tYuWNIlzyEKklNmw5X5PM1idYKlOJflY2dvUGkIqY3lUC9SC2WL+1s7KIw==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-ini@3.972.8': + resolution: {integrity: sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.8': + resolution: {integrity: sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.917.0': resolution: {integrity: sha512-n7HUJ+TgU9wV/Z46yR1rqD9hUjfG50AKi+b5UXTlaDlVD8bckg40i77ROCllp53h32xQj/7H0yBIYyphwzLtmg==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-node@3.972.9': + resolution: {integrity: sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.916.0': resolution: {integrity: sha512-SXDyDvpJ1+WbotZDLJW1lqP6gYGaXfZJrgFSXIuZjHb75fKeNRgPkQX/wZDdUvCwdrscvxmtyJorp2sVYkMcvA==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-process@3.972.8': + resolution: {integrity: sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.916.0': resolution: {integrity: sha512-gu9D+c+U/Dp1AKBcVxYHNNoZF9uD4wjAKYCjgSN37j4tDsazwMEylbbZLuRNuxfbXtizbo4/TiaxBXDbWM7AkQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/credential-provider-sso@3.972.8': + resolution: {integrity: sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.917.0': resolution: {integrity: sha512-pZncQhFbwW04pB0jcD5OFv3x2gAddDYCVxyJVixgyhSw7bKCYxqu6ramfq1NxyVpmm+qsw+ijwi/3cCmhUHF/A==} engines: {node: '>=18.0.0'} deprecated: This version contains a compilation TypeScript error https://github.com/aws/aws-sdk-js-v3/issues/7457 - please use @aws-sdk/credential-providers@3.918.0 or higher + '@aws-sdk/credential-provider-web-identity@3.972.8': + resolution: {integrity: sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/lib-storage@3.917.0': resolution: {integrity: sha512-Z8mRzfP6PgUoybJHx/tH40aop42yeh66cW7wLM8R88egB2UYQ0IgxDoRynqlLi5uceI21wss/CIDkhzO9p1cOg==} engines: {node: '>=18.0.0'} @@ -743,6 +793,10 @@ packages: resolution: {integrity: sha512-7r9ToySQ15+iIgXMF/h616PcQStByylVkCshmQqcdeynD/lCn2l667ynckxW4+ql0Q+Bo/URljuhJRxVJzydNA==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-host-header@3.972.3': + resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-location-constraint@3.914.0': resolution: {integrity: sha512-Mpd0Sm9+GN7TBqGnZg1+dO5QZ/EOYEcDTo7KfvoyrXScMlxvYm9fdrUVMmLdPn/lntweZGV3uNrs+huasGOOTA==} engines: {node: '>=18.0.0'} @@ -751,14 +805,26 @@ packages: resolution: {integrity: sha512-/gaW2VENS5vKvJbcE1umV4Ag3NuiVzpsANxtrqISxT3ovyro29o1RezW/Avz/6oJqjnmgz8soe9J1t65jJdiNg==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-logger@3.972.3': + resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-recursion-detection@3.914.0': resolution: {integrity: sha512-yiAjQKs5S2JKYc+GrkvGMwkUvhepXDigEXpSJqUseR/IrqHhvGNuOxDxq+8LbDhM4ajEW81wkiBbU+Jl9G82yQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-recursion-detection@3.972.3': + resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.916.0': resolution: {integrity: sha512-pjmzzjkEkpJObzmTthqJPq/P13KoNFuEi/x5PISlzJtHofCNcyXeVAQ90yvY2dQ6UXHf511Rh1/ytiKy2A8M0g==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-sdk-sqs@3.972.7': + resolution: {integrity: sha512-DcJLYE4sRjgUyb2SupQGaRgBYc+j89N9nXeMT0PwwVvaBGmKqcxa7PFvz0kBnQrBckPWlfrPyyyMwOeT5BEp6Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-ssec@3.914.0': resolution: {integrity: sha512-V1Oae/oLVbpNb9uWs+v80GKylZCdsbqs2c2Xb1FsAUPtYeSnxFuAWsF3/2AEMSSpFe0dTC5KyWr/eKl2aim9VQ==} engines: {node: '>=18.0.0'} @@ -767,14 +833,26 @@ packages: resolution: {integrity: sha512-mzF5AdrpQXc2SOmAoaQeHpDFsK2GE6EGcEACeNuoESluPI2uYMpuuNMYrUufdnIAIyqgKlis0NVxiahA5jG42w==} engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-user-agent@3.972.10': + resolution: {integrity: sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.916.0': resolution: {integrity: sha512-tgg8e8AnVAer0rcgeWucFJ/uNN67TbTiDHfD+zIOPKep0Z61mrHEoeT/X8WxGIOkEn4W6nMpmS4ii8P42rNtnA==} engines: {node: '>=18.0.0'} + '@aws-sdk/nested-clients@3.990.0': + resolution: {integrity: sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.914.0': resolution: {integrity: sha512-KlmHhRbn1qdwXUdsdrJ7S/MAkkC1jLpQ11n+XvxUUUCGAJd1gjC7AjxPZUM7ieQ2zcb8bfEzIU7al+Q3ZT0u7Q==} engines: {node: '>=18.0.0'} + '@aws-sdk/region-config-resolver@3.972.3': + resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + engines: {node: '>=20.0.0'} + '@aws-sdk/s3-request-presigner@3.917.0': resolution: {integrity: sha512-V1cSM6yQv8lV1Obrp5ti8iXLCRKq45OQETANkiMWRbAwTbzKQml0EfP08BFS+LKtSl2gJfO9tH7O2RgRuqhUuQ==} engines: {node: '>=18.0.0'} @@ -787,10 +865,18 @@ packages: resolution: {integrity: sha512-13GGOEgq5etbXulFCmYqhWtpcEQ6WI6U53dvXbheW0guut8fDFJZmEv7tKMTJgiybxh7JHd0rWcL9JQND8DwoQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/token-providers@3.990.0': + resolution: {integrity: sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.914.0': resolution: {integrity: sha512-kQWPsRDmom4yvAfyG6L1lMmlwnTzm1XwMHOU+G5IFlsP4YEaMtXidDzW/wiivY0QFrhfCz/4TVmu0a2aPU57ug==} engines: {node: '>=18.0.0'} + '@aws-sdk/types@3.973.1': + resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-arn-parser@3.893.0': resolution: {integrity: sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==} engines: {node: '>=18.0.0'} @@ -799,6 +885,14 @@ packages: resolution: {integrity: sha512-bAgUQwvixdsiGNcuZSDAOWbyHlnPtg8G8TyHD6DTfTmKTHUW6tAn+af/ZYJPXEzXhhpwgJqi58vWnsiDhmr7NQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/util-endpoints@3.990.0': + resolution: {integrity: sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.991.0': + resolution: {integrity: sha512-m8tcZ3SbqG3NRDv0Py3iBKdb4/FlpOCP4CQ6wRtsk4vs3UypZ0nFdZwCRVnTN7j+ldj+V72xVi/JBlxFBDE7Sg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.914.0': resolution: {integrity: sha512-QpdkoQjvPaYyzZwgk41vFyHQM5s0DsrsbQ8IoPUggQt4HaJUvmL1ShwMcSldbgdzwiRMqXUK8q7jrqUvkYkY6w==} engines: {node: '>=18.0.0'} @@ -810,6 +904,9 @@ packages: '@aws-sdk/util-user-agent-browser@3.914.0': resolution: {integrity: sha512-rMQUrM1ECH4kmIwlGl9UB0BtbHy6ZuKdWFrIknu8yGTRI/saAucqNTh5EI1vWBxZ0ElhK5+g7zOnUuhSmVQYUA==} + '@aws-sdk/util-user-agent-browser@3.972.3': + resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + '@aws-sdk/util-user-agent-node@3.916.0': resolution: {integrity: sha512-CwfWV2ch6UdjuSV75ZU99N03seEUb31FIUrXBnwa6oONqj/xqXwrxtlUMLx6WH3OJEE4zI3zt5PjlTdGcVwf4g==} engines: {node: '>=18.0.0'} @@ -819,14 +916,31 @@ packages: aws-crt: optional: true + '@aws-sdk/util-user-agent-node@3.972.8': + resolution: {integrity: sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + '@aws-sdk/xml-builder@3.914.0': resolution: {integrity: sha512-k75evsBD5TcIjedycYS7QXQ98AmOtbnxRJOPtCo0IwYRmy7UvqgS/gBL5SmrIqeV6FDSYRQMgdBxSMp6MLmdew==} engines: {node: '>=18.0.0'} + '@aws-sdk/xml-builder@3.972.4': + resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.0.1': resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==} engines: {node: '>=18.0.0'} + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1332,6 +1446,9 @@ packages: resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -1371,6 +1488,36 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1669,6 +1816,10 @@ packages: resolution: {integrity: sha512-xWL9Mf8b7tIFuAlpjKtRPnHrR8XVrwTj5NPYO/QwZPtc0SDLsPxb56V5tzi5yspSMytISHybifez+4jlrx0vkQ==} engines: {node: '>=18.0.0'} + '@smithy/abort-controller@4.2.8': + resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + engines: {node: '>=18.0.0'} + '@smithy/chunked-blob-reader-native@4.2.1': resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} engines: {node: '>=18.0.0'} @@ -1681,14 +1832,26 @@ packages: resolution: {integrity: sha512-Kkmz3Mup2PGp/HNJxhCWkLNdlajJORLSjwkcfrj0E7nu6STAEdcMR1ir5P9/xOmncx8xXfru0fbUYLlZog/cFg==} engines: {node: '>=18.0.0'} + '@smithy/config-resolver@4.4.6': + resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + engines: {node: '>=18.0.0'} + '@smithy/core@3.17.1': resolution: {integrity: sha512-V4Qc2CIb5McABYfaGiIYLTmo/vwNIK7WXI5aGveBd9UcdhbOMwcvIMxIw/DJj1S9QgOMa/7FBkarMdIC0EOTEQ==} engines: {node: '>=18.0.0'} + '@smithy/core@3.23.0': + resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.3': resolution: {integrity: sha512-hA1MQ/WAHly4SYltJKitEsIDVsNmXcQfYBRv2e+q04fnqtAX5qXaybxy/fhUeAMCnQIdAjaGDb04fMHQefWRhw==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.8': + resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.3': resolution: {integrity: sha512-rcr0VH0uNoMrtgKuY7sMfyKqbHc4GQaQ6Yp4vwgm+Z6psPuOgL+i/Eo/QWdXRmMinL3EgFM0Z1vkfyPyfzLmjw==} engines: {node: '>=18.0.0'} @@ -1713,6 +1876,10 @@ packages: resolution: {integrity: sha512-bwigPylvivpRLCm+YK9I5wRIYjFESSVwl8JQ1vVx/XhCw0PtCi558NwTnT2DaVCl5pYlImGuQTSwMsZ+pIavRw==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.9': + resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + engines: {node: '>=18.0.0'} + '@smithy/hash-blob-browser@4.2.4': resolution: {integrity: sha512-W7eIxD+rTNsLB/2ynjmbdeP7TgxRXprfvqQxKFEfy9HW2HeD7t+g+KCIrY0pIn/GFjA6/fIpH+JQnfg5TTk76Q==} engines: {node: '>=18.0.0'} @@ -1721,6 +1888,10 @@ packages: resolution: {integrity: sha512-6+NOdZDbfuU6s1ISp3UOk5Rg953RJ2aBLNLLBEcamLjHAg1Po9Ha7QIB5ZWhdRUVuOUrT8BVFR+O2KIPmw027g==} engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.8': + resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + engines: {node: '>=18.0.0'} + '@smithy/hash-stream-node@4.2.3': resolution: {integrity: sha512-EXMSa2yiStVII3x/+BIynyOAZlS7dGvI7RFrzXa/XssBgck/7TXJIvnjnCu328GY/VwHDC4VeDyP1S4rqwpYag==} engines: {node: '>=18.0.0'} @@ -1729,6 +1900,10 @@ packages: resolution: {integrity: sha512-Cc9W5DwDuebXEDMpOpl4iERo8I0KFjTnomK2RMdhhR87GwrSmUmwMxS4P5JdRf+LsjOdIqumcerwRgYMr/tZ9Q==} engines: {node: '>=18.0.0'} + '@smithy/invalid-dependency@4.2.8': + resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} @@ -1741,14 +1916,30 @@ packages: resolution: {integrity: sha512-5+4bUEJQi/NRgzdA5SVXvAwyvEnD0ZAiKzV3yLO6dN5BG8ScKBweZ8mxXXUtdxq+Dx5k6EshKk0XJ7vgvIPSnA==} engines: {node: '>=18.0.0'} + '@smithy/md5-js@4.2.8': + resolution: {integrity: sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-content-length@4.2.3': resolution: {integrity: sha512-/atXLsT88GwKtfp5Jr0Ks1CSa4+lB+IgRnkNrrYP0h1wL4swHNb0YONEvTceNKNdZGJsye+W2HH8W7olbcPUeA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-content-length@4.2.8': + resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.3.5': resolution: {integrity: sha512-SIzKVTvEudFWJbxAaq7f2GvP3jh2FHDpIFI6/VAf4FOWGFZy0vnYMPSRj8PGYI8Hjt29mvmwSRgKuO3bK4ixDw==} engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.14': + resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.31': + resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.5': resolution: {integrity: sha512-DCaXbQqcZ4tONMvvdz+zccDE21sLcbwWoNqzPLFlZaxt1lDtOE2tlVpRSwcTOJrjJSUThdgEYn7HrX5oLGlK9A==} engines: {node: '>=18.0.0'} @@ -1757,14 +1948,30 @@ packages: resolution: {integrity: sha512-8g4NuUINpYccxiCXM5s1/V+uLtts8NcX4+sPEbvYQDZk4XoJfDpq5y2FQxfmUL89syoldpzNzA0R9nhzdtdKnQ==} engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.9': + resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.3': resolution: {integrity: sha512-iGuOJkH71faPNgOj/gWuEGS6xvQashpLwWB1HjHq1lNNiVfbiJLpZVbhddPuDbx9l4Cgl0vPLq5ltRfSaHfspA==} engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.8': + resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.3': resolution: {integrity: sha512-NzI1eBpBSViOav8NVy1fqOlSfkLgkUjUTlohUSgAEhHaFWA3XJiLditvavIP7OpvTjDp5u2LhtlBhkBlEisMwA==} engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.8': + resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.10': + resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.3': resolution: {integrity: sha512-MAwltrDB0lZB/H6/2M5PIsISSwdI5yIh6DaBB9r0Flo9nx3y0dzl/qTMJPd7tJvPdsx6Ks/cwVzheGNYzXyNbQ==} engines: {node: '>=18.0.0'} @@ -1773,34 +1980,70 @@ packages: resolution: {integrity: sha512-+1EZ+Y+njiefCohjlhyOcy1UNYjT+1PwGFHCxA/gYctjg3DQWAU19WigOXAco/Ql8hZokNehpzLd0/+3uCreqQ==} engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.8': + resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.3': resolution: {integrity: sha512-Mn7f/1aN2/jecywDcRDvWWWJF4uwg/A0XjFMJtj72DsgHTByfjRltSqcT9NyE9RTdBSN6X1RSXrhn/YWQl8xlw==} engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.8': + resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.3': resolution: {integrity: sha512-LOVCGCmwMahYUM/P0YnU/AlDQFjcu+gWbFJooC417QRB/lDJlWSn8qmPSDp+s4YVAHOgtgbNG4sR+SxF/VOcJQ==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.8': + resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.3': resolution: {integrity: sha512-cYlSNHcTAX/wc1rpblli3aUlLMGgKZ/Oqn8hhjFASXMCXjIqeuQBei0cnq2JR8t4RtU9FpG6uyl6PxyArTiwKA==} engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.8': + resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.3': resolution: {integrity: sha512-NkxsAxFWwsPsQiwFG2MzJ/T7uIR6AQNh1SzcxSUnmmIqIQMlLRQDKhc17M7IYjiuBXhrQRjQTo3CxX+DobS93g==} engines: {node: '>=18.0.0'} + '@smithy/service-error-classification@4.2.8': + resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.3.3': resolution: {integrity: sha512-9f9Ixej0hFhroOK2TxZfUUDR13WVa8tQzhSzPDgXe5jGL3KmaM9s8XN7RQwqtEypI82q9KHnKS71CJ+q/1xLtQ==} engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.3': + resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.3': resolution: {integrity: sha512-CmSlUy+eEYbIEYN5N3vvQTRfqt0lJlQkaQUIf+oizu7BbDut0pozfDjBGecfcfWf7c62Yis4JIEgqQ/TCfodaA==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.8': + resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.11.3': + resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.9.1': resolution: {integrity: sha512-Ngb95ryR5A9xqvQFT5mAmYkCwbXvoLavLFwmi7zVg/IowFPCfiqRfkOKnbc/ZRL8ZKJ4f+Tp6kSu6wjDQb8L/g==} engines: {node: '>=18.0.0'} + '@smithy/types@4.12.0': + resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.8.0': resolution: {integrity: sha512-QpELEHLO8SsQVtqP+MkEgCYTFW0pleGozfs3cZ183ZBj9z3VC1CX1/wtFMK64p+5bhtZo41SeLK1rBRtd25nHQ==} engines: {node: '>=18.0.0'} @@ -1809,6 +2052,10 @@ packages: resolution: {integrity: sha512-I066AigYvY3d9VlU3zG9XzZg1yT10aNqvCaBTw9EPgu5GrsEl1aUkcMvhkIXascYH1A8W0LQo3B1Kr1cJNcQEw==} engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.8': + resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + engines: {node: '>=18.0.0'} + '@smithy/util-base64@4.3.0': resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} engines: {node: '>=18.0.0'} @@ -1833,10 +2080,18 @@ packages: resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.30': + resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-browser@4.3.4': resolution: {integrity: sha512-qI5PJSW52rnutos8Bln8nwQZRpyoSRN6k2ajyoUHNMUzmWqHnOJCnDELJuV6m5PML0VkHI+XcXzdB+6awiqYUw==} engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.33': + resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} + engines: {node: '>=18.0.0'} + '@smithy/util-defaults-mode-node@4.2.6': resolution: {integrity: sha512-c6M/ceBTm31YdcFpgfgQAJaw3KbaLuRKnAz91iMWFLSrgxRpYm03c3bu5cpYojNMfkV9arCUelelKA7XQT36SQ==} engines: {node: '>=18.0.0'} @@ -1845,6 +2100,10 @@ packages: resolution: {integrity: sha512-aCfxUOVv0CzBIkU10TubdgKSx5uRvzH064kaiPEWfNIvKOtNpu642P4FP1hgOFkjQIkDObrfIDnKMKkeyrejvQ==} engines: {node: '>=18.0.0'} + '@smithy/util-endpoints@3.2.8': + resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + engines: {node: '>=18.0.0'} + '@smithy/util-hex-encoding@4.2.0': resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} engines: {node: '>=18.0.0'} @@ -1853,10 +2112,22 @@ packages: resolution: {integrity: sha512-v5ObKlSe8PWUHCqEiX2fy1gNv6goiw6E5I/PN2aXg3Fb/hse0xeaAnSpXDiWl7x6LamVKq7senB+m5LOYHUAHw==} engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.8': + resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.3': resolution: {integrity: sha512-lLPWnakjC0q9z+OtiXk+9RPQiYPNAovt2IXD3CP4LkOnd9NpUsxOjMx1SnoUVB7Orb7fZp67cQMtTBKMFDvOGg==} engines: {node: '>=18.0.0'} + '@smithy/util-retry@4.2.8': + resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.12': + resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} + engines: {node: '>=18.0.0'} + '@smithy/util-stream@4.5.4': resolution: {integrity: sha512-+qDxSkiErejw1BAIXUFBSfM5xh3arbz1MmxlbMCKanDDZtVEQ7PSKW9FQS0Vud1eI/kYn0oCTVKyNzRlq+9MUw==} engines: {node: '>=18.0.0'} @@ -2438,6 +2709,9 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} + bullmq@5.69.3: + resolution: {integrity: sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -2573,6 +2847,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2692,6 +2970,10 @@ packages: resolution: {integrity: sha512-JfZ9NPLsU9ejTYgZ7fM+5TIMfTwROTxpi2Twh597GxmiVDwIGZSjaor+zsQBKZ0mmCKOFb9EZZLVeKNf/5UaGg==} engines: {node: '>=8.0'} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -2796,6 +3078,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2807,6 +3093,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -3234,6 +3524,10 @@ packages: resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} hasBin: true + fast-xml-parser@5.3.4: + resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + hasBin: true + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -3718,6 +4012,10 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} + ioredis@5.9.2: + resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} + engines: {node: '>=12.22.0'} + ipaddr.js@2.2.0: resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} engines: {node: '>= 10'} @@ -4098,9 +4396,15 @@ packages: lodash.clonedeep@4.5.0: resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -4178,6 +4482,10 @@ packages: lru-memoizer@2.3.0: resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + macos-release@2.5.1: resolution: {integrity: sha512-DXqXhEM7gW59OjZO8NIjBCz9AQ1BEMrfiOAl4AYByHCtVHRF4KoGNO8mqQeM8lRCtQe/UnJ4imO/d2HdkKsd+A==} engines: {node: '>=6'} @@ -4417,6 +4725,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -4453,6 +4768,9 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-cron@4.2.1: resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} engines: {node: '>=6.0.0'} @@ -4470,6 +4788,10 @@ packages: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-releases@2.0.26: resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==} @@ -5095,6 +5417,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5247,6 +5577,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + sentence-case@3.0.4: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} @@ -5417,6 +5752,9 @@ packages: stacktracey@2.1.8: resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -6126,7 +6464,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.914.0 + '@aws-sdk/types': 3.973.1 '@aws-sdk/util-locate-window': 3.893.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6134,7 +6472,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.914.0 + '@aws-sdk/types': 3.973.1 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -6254,6 +6592,52 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sqs@3.991.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-node': 3.972.9 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-sdk-sqs': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.991.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/md5-js': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-sso@3.916.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -6297,6 +6681,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sso@3.990.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/core@3.916.0': dependencies: '@aws-sdk/types': 3.914.0 @@ -6313,6 +6740,22 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/core@3.973.10': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/xml-builder': 3.972.4 + '@smithy/core': 3.23.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.916.0': dependencies: '@aws-sdk/core': 3.916.0 @@ -6321,6 +6764,14 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.916.0': dependencies: '@aws-sdk/core': 3.916.0 @@ -6334,6 +6785,19 @@ snapshots: '@smithy/util-stream': 4.5.4 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.10 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.12 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.917.0': dependencies: '@aws-sdk/core': 3.916.0 @@ -6352,6 +6816,38 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/credential-provider-env': 3.972.8 + '@aws-sdk/credential-provider-http': 3.972.10 + '@aws-sdk/credential-provider-login': 3.972.8 + '@aws-sdk/credential-provider-process': 3.972.8 + '@aws-sdk/credential-provider-sso': 3.972.8 + '@aws-sdk/credential-provider-web-identity': 3.972.8 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-node@3.917.0': dependencies: '@aws-sdk/credential-provider-env': 3.916.0 @@ -6369,6 +6865,23 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.9': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.8 + '@aws-sdk/credential-provider-http': 3.972.10 + '@aws-sdk/credential-provider-ini': 3.972.8 + '@aws-sdk/credential-provider-process': 3.972.8 + '@aws-sdk/credential-provider-sso': 3.972.8 + '@aws-sdk/credential-provider-web-identity': 3.972.8 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-process@3.916.0': dependencies: '@aws-sdk/core': 3.916.0 @@ -6378,6 +6891,15 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.916.0': dependencies: '@aws-sdk/client-sso': 3.916.0 @@ -6391,6 +6913,19 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.8': + dependencies: + '@aws-sdk/client-sso': 3.990.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/token-providers': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/credential-provider-web-identity@3.917.0': dependencies: '@aws-sdk/core': 3.916.0 @@ -6403,6 +6938,18 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.8': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/lib-storage@3.917.0(@aws-sdk/client-s3@3.917.0)': dependencies: '@aws-sdk/client-s3': 3.917.0 @@ -6454,6 +7001,13 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/middleware-location-constraint@3.914.0': dependencies: '@aws-sdk/types': 3.914.0 @@ -6466,6 +7020,12 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/middleware-logger@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.914.0': dependencies: '@aws-sdk/types': 3.914.0 @@ -6474,6 +7034,14 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/middleware-recursion-detection@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.916.0': dependencies: '@aws-sdk/core': 3.916.0 @@ -6491,6 +7059,15 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/middleware-sdk-sqs@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@aws-sdk/middleware-ssec@3.914.0': dependencies: '@aws-sdk/types': 3.914.0 @@ -6507,6 +7084,16 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/middleware-user-agent@3.972.10': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@smithy/core': 3.23.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.916.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -6550,6 +7137,49 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.990.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.10 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.990.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.8 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.23.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.10 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/region-config-resolver@3.914.0': dependencies: '@aws-sdk/types': 3.914.0 @@ -6557,6 +7187,14 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/s3-request-presigner@3.917.0': dependencies: '@aws-sdk/signature-v4-multi-region': 3.916.0 @@ -6589,11 +7227,28 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.990.0': + dependencies: + '@aws-sdk/core': 3.973.10 + '@aws-sdk/nested-clients': 3.990.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/types@3.914.0': dependencies: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/types@3.973.1': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.893.0': dependencies: tslib: 2.8.1 @@ -6606,6 +7261,22 @@ snapshots: '@smithy/util-endpoints': 3.2.3 tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.990.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.991.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + '@aws-sdk/util-format-url@3.914.0': dependencies: '@aws-sdk/types': 3.914.0 @@ -6624,6 +7295,13 @@ snapshots: bowser: 2.12.1 tslib: 2.8.1 + '@aws-sdk/util-user-agent-browser@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + bowser: 2.12.1 + tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.916.0': dependencies: '@aws-sdk/middleware-user-agent': 3.916.0 @@ -6632,14 +7310,30 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@aws-sdk/util-user-agent-node@3.972.8': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.10 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.914.0': dependencies: '@smithy/types': 4.8.0 fast-xml-parser: 5.2.5 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.4': + dependencies: + '@smithy/types': 4.12.0 + fast-xml-parser: 5.3.4 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.0.1': {} + '@aws/lambda-invoke-store@0.2.3': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6786,7 +7480,7 @@ snapshots: '@commitlint/is-ignored@19.8.1': dependencies: '@commitlint/types': 19.8.1 - semver: 7.7.3 + semver: 7.7.4 '@commitlint/lint@19.8.1': dependencies: @@ -7234,6 +7928,8 @@ snapshots: '@hutson/parse-repository-url@3.0.2': {} + '@ioredis/commands@1.5.0': {} + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -7275,6 +7971,24 @@ snapshots: '@lukeed/ms@2.0.2': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -7631,6 +8345,11 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/abort-controller@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/chunked-blob-reader-native@4.2.1': dependencies: '@smithy/util-base64': 4.3.0 @@ -7649,6 +8368,15 @@ snapshots: '@smithy/util-middleware': 4.2.3 tslib: 2.8.1 + '@smithy/config-resolver@4.4.6': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + '@smithy/core@3.17.1': dependencies: '@smithy/middleware-serde': 4.2.3 @@ -7662,6 +8390,19 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 + '@smithy/core@3.23.0': + dependencies: + '@smithy/middleware-serde': 4.2.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.12 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.3': dependencies: '@smithy/node-config-provider': 4.3.3 @@ -7670,6 +8411,14 @@ snapshots: '@smithy/url-parser': 4.2.3 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.3': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -7708,6 +8457,14 @@ snapshots: '@smithy/util-base64': 4.3.0 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + '@smithy/hash-blob-browser@4.2.4': dependencies: '@smithy/chunked-blob-reader': 5.2.0 @@ -7722,6 +8479,13 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/hash-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@smithy/hash-stream-node@4.2.3': dependencies: '@smithy/types': 4.8.0 @@ -7733,6 +8497,11 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/invalid-dependency@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 @@ -7747,12 +8516,24 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/md5-js@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@smithy/middleware-content-length@4.2.3': dependencies: '@smithy/protocol-http': 5.3.3 '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/middleware-content-length@4.2.8': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/middleware-endpoint@4.3.5': dependencies: '@smithy/core': 3.17.1 @@ -7764,6 +8545,29 @@ snapshots: '@smithy/util-middleware': 4.2.3 tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.14': + dependencies: + '@smithy/core': 3.23.0 + '@smithy/middleware-serde': 4.2.9 + '@smithy/node-config-provider': 4.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.31': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/service-error-classification': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + '@smithy/middleware-retry@4.4.5': dependencies: '@smithy/node-config-provider': 4.3.3 @@ -7782,11 +8586,22 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/middleware-serde@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/middleware-stack@4.2.3': dependencies: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/middleware-stack@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/node-config-provider@4.3.3': dependencies: '@smithy/property-provider': 4.2.3 @@ -7794,6 +8609,21 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/node-config-provider@4.3.8': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.10': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/node-http-handler@4.4.3': dependencies: '@smithy/abort-controller': 4.2.3 @@ -7807,31 +8637,61 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/property-provider@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/protocol-http@5.3.3': dependencies: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/protocol-http@5.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/querystring-builder@4.2.3': dependencies: '@smithy/types': 4.8.0 '@smithy/util-uri-escape': 4.2.0 tslib: 2.8.1 + '@smithy/querystring-builder@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + '@smithy/querystring-parser@4.2.3': dependencies: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/querystring-parser@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/service-error-classification@4.2.3': dependencies: '@smithy/types': 4.8.0 + '@smithy/service-error-classification@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/shared-ini-file-loader@4.3.3': dependencies: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.4.3': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/signature-v4@5.3.3': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -7843,6 +8703,27 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@smithy/signature-v4@5.3.8': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.11.3': + dependencies: + '@smithy/core': 3.23.0 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-stack': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.12 + tslib: 2.8.1 + '@smithy/smithy-client@4.9.1': dependencies: '@smithy/core': 3.17.1 @@ -7853,6 +8734,10 @@ snapshots: '@smithy/util-stream': 4.5.4 tslib: 2.8.1 + '@smithy/types@4.12.0': + dependencies: + tslib: 2.8.1 + '@smithy/types@4.8.0': dependencies: tslib: 2.8.1 @@ -7863,6 +8748,12 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/url-parser@4.2.8': + dependencies: + '@smithy/querystring-parser': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-base64@4.3.0': dependencies: '@smithy/util-buffer-from': 4.2.0 @@ -7891,6 +8782,13 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.30': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-defaults-mode-browser@4.3.4': dependencies: '@smithy/property-provider': 4.2.3 @@ -7898,6 +8796,16 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.33': + dependencies: + '@smithy/config-resolver': 4.4.6 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-defaults-mode-node@4.2.6': dependencies: '@smithy/config-resolver': 4.4.0 @@ -7914,6 +8822,12 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/util-endpoints@3.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-hex-encoding@4.2.0': dependencies: tslib: 2.8.1 @@ -7923,12 +8837,34 @@ snapshots: '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/util-middleware@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/util-retry@4.2.3': dependencies: '@smithy/service-error-classification': 4.2.3 '@smithy/types': 4.8.0 tslib: 2.8.1 + '@smithy/util-retry@4.2.8': + dependencies: + '@smithy/service-error-classification': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.12': + dependencies: + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.10 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + '@smithy/util-stream@4.5.4': dependencies: '@smithy/fetch-http-handler': 5.3.4 @@ -8190,7 +9126,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.54.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -8588,6 +9524,18 @@ snapshots: builtin-modules@5.0.0: {} + bullmq@5.69.3: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.9.2 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.4 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -8764,6 +9712,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -8899,6 +9849,10 @@ snapshots: crack-json@1.3.0: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -8998,12 +9952,17 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} deprecation@2.3.1: {} dequal@2.0.3: {} + detect-libc@2.1.2: + optional: true + detect-node@2.1.0: {} dir-glob@3.0.1: @@ -9091,7 +10050,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.7.3 + semver: 7.7.4 ejs@3.1.10: dependencies: @@ -9254,7 +10213,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - semver: 7.7.3 + semver: 7.7.4 eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: @@ -9352,7 +10311,7 @@ snapshots: globals: 15.15.0 ignore: 5.3.2 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 ts-declaration-location: 1.0.7(typescript@5.9.3) transitivePeerDependencies: - supports-color @@ -9400,7 +10359,7 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.13.0 - semver: 7.7.3 + semver: 7.7.4 strip-indent: 4.1.1 eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))): @@ -9410,7 +10369,7 @@ snapshots: natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 - semver: 7.7.3 + semver: 7.7.4 vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: @@ -9587,6 +10546,10 @@ snapshots: dependencies: strnum: 2.1.1 + fast-xml-parser@5.3.4: + dependencies: + strnum: 2.1.1 + fastify-plugin@5.1.0: {} fastify@5.6.1: @@ -10198,6 +11161,20 @@ snapshots: interpret@1.4.0: {} + ioredis@5.9.2: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@2.2.0: {} is-array-buffer@3.0.5: @@ -10235,7 +11212,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 is-callable@1.2.7: {} @@ -10372,7 +11349,7 @@ snapshots: '@babel/parser': 7.28.5 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -10498,7 +11475,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.3 + semver: 7.7.4 juice@10.0.1: dependencies: @@ -10596,8 +11573,12 @@ snapshots: lodash.clonedeep@4.5.0: {} + lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} lodash.isinteger@4.0.4: {} @@ -10658,6 +11639,8 @@ snapshots: lodash.clonedeep: 4.5.0 lru-cache: 6.0.0 + luxon@3.7.2: {} + macos-release@2.5.1: {} magic-string@0.30.21: @@ -10676,7 +11659,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 map-obj@1.0.1: {} @@ -11096,6 +12079,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + mustache@4.2.0: {} mute-stream@0.0.8: {} @@ -11126,6 +12125,8 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-abort-controller@3.1.1: {} + node-cron@4.2.1: {} node-fetch@2.7.0: @@ -11134,6 +12135,11 @@ snapshots: node-forge@1.3.1: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-releases@2.0.26: {} node-releases@2.0.27: {} @@ -11169,7 +12175,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.16.1 - semver: 7.7.3 + semver: 7.7.4 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -11731,6 +12737,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -11896,6 +12908,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + sentence-case@3.0.4: dependencies: no-case: 3.0.4 @@ -12119,6 +13133,8 @@ snapshots: as-table: 1.0.55 get-source: 2.0.12 + standard-as-callback@2.1.0: {} + statuses@2.0.1: {} std-env@3.10.0: {} @@ -12672,7 +13688,7 @@ snapshots: eslint-visitor-keys: 4.2.1 espree: 10.4.0 esquery: 1.6.0 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color From 8acc9bcf75d710c32b25cd7394a59c9f682ec6a3 Mon Sep 17 00:00:00 2001 From: sudeep Date: Tue, 17 Feb 2026 14:45:37 +0545 Subject: [PATCH 05/22] feat: add bullmq client --- packages/worker/src/queue/bullmq.ts | 66 +++++++++++++++++++++++++++++ packages/worker/src/queue/index.ts | 16 +++++++ 2 files changed, 82 insertions(+) create mode 100644 packages/worker/src/queue/bullmq.ts create mode 100644 packages/worker/src/queue/index.ts diff --git a/packages/worker/src/queue/bullmq.ts b/packages/worker/src/queue/bullmq.ts new file mode 100644 index 000000000..95dcf88c4 --- /dev/null +++ b/packages/worker/src/queue/bullmq.ts @@ -0,0 +1,66 @@ +import { Queue as BullQueue, Worker, Job, RedisOptions } from "bullmq"; + +import { QueueConfig } from "../types/queue"; + +import { Queue } from "."; + +class BullMQQueue extends Queue { + private queue: BullQueue; + private worker?: Worker; + private connection: RedisOptions; + + constructor(config: Required>) { + super(config.name); + + this.connection = config.bullmqConfig.connection; + this.queue = new BullQueue(this.queueName, { + connection: this.connection, + defaultJobOptions: config.bullmqConfig.defaultJobOptions, + }); + } + + async push(data: T, options?: Record): Promise { + try { + const job = await this.queue.add(this.queueName, data, options); + + return job.id!; + } catch (error) { + throw new Error( + `Failed to push job to BullMQ queue: ${this.queueName}. Error: ${(error as Error).message}`, + ); + } + } + + process(handler: (data: T) => Promise, concurrency = 1): void { + try { + this.worker = new Worker( + this.queueName, + async (job: Job) => { + await handler(job.data as T); + }, + { + connection: this.connection, + concurrency, + }, + ); + + this.worker.on("error", (error) => { + console.error( + `Error in BullMQ worker for queue: ${this.queueName}. Error: ${(error as Error).message}`, + ); + }); + + this.worker.on("failed", (job, error) => { + console.error( + `Job failed in BullMQ queue: ${this.queueName}. Job ID: ${job?.id}. Error: ${(error as Error).message}`, + ); + }); + } catch (error) { + throw new Error( + `Failed to process jobs from BullMQ queue: ${this.queueName}. Error: ${(error as Error).message}`, + ); + } + } +} + +export default BullMQQueue; diff --git a/packages/worker/src/queue/index.ts b/packages/worker/src/queue/index.ts new file mode 100644 index 000000000..8d45b4721 --- /dev/null +++ b/packages/worker/src/queue/index.ts @@ -0,0 +1,16 @@ +abstract class Queue { + protected queueName: string; + + constructor(name: string) { + this.queueName = name; + } + + abstract push(data: T, options?: Record): Promise; + + abstract process( + handler: (data: T) => Promise, + concurrency?: number, + ): void; +} + +export { Queue }; From c32876f84909b39c7fed57a7de09958031ec2b18 Mon Sep 17 00:00:00 2001 From: sudeep Date: Tue, 17 Feb 2026 16:50:27 +0545 Subject: [PATCH 06/22] feat: add support for triggering bullmq queue from app --- packages/worker/package.json | 1 + packages/worker/src/index.ts | 6 +++++ packages/worker/src/plugin.ts | 9 +++++-- packages/worker/src/queue/bullmq.ts | 11 +++++--- packages/worker/src/queue/index.ts | 26 +++++++++++++++++- packages/worker/src/queue/setup.ts | 41 +++++++++++++++++++++++++++++ packages/worker/src/types/queue.ts | 4 ++- 7 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 packages/worker/src/queue/setup.ts diff --git a/packages/worker/package.json b/packages/worker/package.json index 9bd731f7a..287a15487 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -9,6 +9,7 @@ "directory": "packages/worker" }, "license": "MIT", + "type": "module", "exports": { ".": { "import": "./dist/prefabs-tech-fastify-worker.js", diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index c4a5476e6..7cd985e69 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -8,4 +8,10 @@ declare module "@prefabs.tech/fastify-config" { } } +export * from "./enum"; + +export { default } from "./plugin"; + +export { addToQueue } from "./queue"; + export type { WorkerConfig } from "./types"; diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index 71909cf03..96e82b160 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -1,7 +1,8 @@ import { FastifyInstance } from "fastify"; -import fastifyPlugin from "fastify-plugin"; +import FastifyPlugin from "fastify-plugin"; import setupCronJobs from "./cron/setup"; +import setupQueues from "./queue/setup"; const plugin = async (fastify: FastifyInstance) => { const { config, log } = fastify; @@ -17,6 +18,10 @@ const plugin = async (fastify: FastifyInstance) => { if (config.worker.cronJobs) { setupCronJobs(config.worker); } + + if (config.worker.queues) { + setupQueues(config.worker); + } }; -export default fastifyPlugin(plugin); +export default FastifyPlugin(plugin); diff --git a/packages/worker/src/queue/bullmq.ts b/packages/worker/src/queue/bullmq.ts index 95dcf88c4..e3b9ea6f6 100644 --- a/packages/worker/src/queue/bullmq.ts +++ b/packages/worker/src/queue/bullmq.ts @@ -9,7 +9,10 @@ class BullMQQueue extends Queue { private worker?: Worker; private connection: RedisOptions; - constructor(config: Required>) { + constructor( + config: Required> & + Pick, + ) { super(config.name); this.connection = config.bullmqConfig.connection; @@ -17,6 +20,8 @@ class BullMQQueue extends Queue { connection: this.connection, defaultJobOptions: config.bullmqConfig.defaultJobOptions, }); + + this.process(config.handler, config.concurrency); } async push(data: T, options?: Record): Promise { @@ -31,12 +36,12 @@ class BullMQQueue extends Queue { } } - process(handler: (data: T) => Promise, concurrency = 1): void { + process(handler: (job: Job) => Promise, concurrency = 1): void { try { this.worker = new Worker( this.queueName, async (job: Job) => { - await handler(job.data as T); + await handler(job); }, { connection: this.connection, diff --git a/packages/worker/src/queue/index.ts b/packages/worker/src/queue/index.ts index 8d45b4721..272f993da 100644 --- a/packages/worker/src/queue/index.ts +++ b/packages/worker/src/queue/index.ts @@ -13,4 +13,28 @@ abstract class Queue { ): void; } -export { Queue }; +const queueRegistry = new Map(); + +const registerQueue = (name: string, queue: Queue): void => { + queueRegistry.set(name, queue); +}; + +const getQueue = (name: string): Queue | undefined => { + return queueRegistry.get(name); +}; + +const addToQueue = async ( + queueName: string, + data: T, + options?: Record, +): Promise => { + const queue = getQueue(queueName); + + if (!queue) { + throw new Error(`Queue not found: ${queueName}`); + } + + return queue.push(data, options); +}; + +export { Queue, registerQueue, addToQueue }; diff --git a/packages/worker/src/queue/setup.ts b/packages/worker/src/queue/setup.ts new file mode 100644 index 000000000..50ba263d9 --- /dev/null +++ b/packages/worker/src/queue/setup.ts @@ -0,0 +1,41 @@ +import { QueueProvider } from "../enum"; +import { WorkerConfig } from "../types"; +import BullMQQueue from "./bullmq"; + +import { registerQueue } from "."; + +const setupQueues = (config: WorkerConfig) => { + if (!config.queues || config.queues.length === 0) { + return; + } + + const { queues } = config; + + for (const queueConfig of queues) { + switch (queueConfig.provider) { + case QueueProvider.BULLMQ: { + if (!queueConfig.bullmqConfig) { + throw new Error( + `BullMQ configuration is required for queue: ${queueConfig.name}`, + ); + } + + const queue = new BullMQQueue({ + name: queueConfig.name, + bullmqConfig: queueConfig.bullmqConfig, + handler: queueConfig.handler, + concurrency: queueConfig.concurrency, + }); + + registerQueue(queueConfig.name, queue); + + break; + } + default: { + throw new Error(`Unsupported queue provider: ${queueConfig.provider}`); + } + } + } +}; + +export default setupQueues; diff --git a/packages/worker/src/types/queue.ts b/packages/worker/src/types/queue.ts index 691098678..a554e6c56 100644 --- a/packages/worker/src/types/queue.ts +++ b/packages/worker/src/types/queue.ts @@ -1,4 +1,4 @@ -import { RedisOptions } from "bullmq"; +import { RedisOptions, Job } from "bullmq"; import { QueueProvider } from "../enum"; @@ -15,6 +15,8 @@ export interface QueueConfig { removeOnFail?: boolean | number; }; }; + handler: (job: Job) => Promise; + concurrency?: number; name: string; provider: QueueProvider; } From 89e313daeebb3b24a97d515afea0d8768bdca45a Mon Sep 17 00:00:00 2001 From: Sudeep Bashistha <32100152+BashisthaSudeep@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:14:17 +0545 Subject: [PATCH 07/22] feat: add sqs support for queue processing in worker (#1068) --- packages/worker/src/queue/bullmq.ts | 7 +- packages/worker/src/queue/index.ts | 12 ++-- packages/worker/src/queue/setup.ts | 20 +++++- packages/worker/src/queue/sqs.ts | 99 +++++++++++++++++++++++++++++ packages/worker/src/types/queue.ts | 14 +++- 5 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 packages/worker/src/queue/sqs.ts diff --git a/packages/worker/src/queue/bullmq.ts b/packages/worker/src/queue/bullmq.ts index e3b9ea6f6..c7e986610 100644 --- a/packages/worker/src/queue/bullmq.ts +++ b/packages/worker/src/queue/bullmq.ts @@ -9,10 +9,7 @@ class BullMQQueue extends Queue { private worker?: Worker; private connection: RedisOptions; - constructor( - config: Required> & - Pick, - ) { + constructor(config: Required>) { super(config.name); this.connection = config.bullmqConfig.connection; @@ -21,7 +18,7 @@ class BullMQQueue extends Queue { defaultJobOptions: config.bullmqConfig.defaultJobOptions, }); - this.process(config.handler, config.concurrency); + this.process(config.bullmqConfig.handler, config.bullmqConfig.concurrency); } async push(data: T, options?: Record): Promise { diff --git a/packages/worker/src/queue/index.ts b/packages/worker/src/queue/index.ts index 272f993da..317cb1d76 100644 --- a/packages/worker/src/queue/index.ts +++ b/packages/worker/src/queue/index.ts @@ -5,24 +5,24 @@ abstract class Queue { this.queueName = name; } - abstract push(data: T, options?: Record): Promise; - abstract process( handler: (data: T) => Promise, concurrency?: number, ): void; + + abstract push(data: T, options?: Record): Promise; } const queueRegistry = new Map(); -const registerQueue = (name: string, queue: Queue): void => { - queueRegistry.set(name, queue); -}; - const getQueue = (name: string): Queue | undefined => { return queueRegistry.get(name); }; +const registerQueue = (name: string, queue: Queue): void => { + queueRegistry.set(name, queue); +}; + const addToQueue = async ( queueName: string, data: T, diff --git a/packages/worker/src/queue/setup.ts b/packages/worker/src/queue/setup.ts index 50ba263d9..e102999a3 100644 --- a/packages/worker/src/queue/setup.ts +++ b/packages/worker/src/queue/setup.ts @@ -1,6 +1,7 @@ import { QueueProvider } from "../enum"; import { WorkerConfig } from "../types"; import BullMQQueue from "./bullmq"; +import SQSQueue from "./sqs"; import { registerQueue } from "."; @@ -23,8 +24,23 @@ const setupQueues = (config: WorkerConfig) => { const queue = new BullMQQueue({ name: queueConfig.name, bullmqConfig: queueConfig.bullmqConfig, - handler: queueConfig.handler, - concurrency: queueConfig.concurrency, + }); + + registerQueue(queueConfig.name, queue); + + break; + } + + case QueueProvider.SQS: { + if (!queueConfig.sqsConfig) { + throw new Error( + `SQS configuration is required for queue: ${queueConfig.name}`, + ); + } + + const queue = new SQSQueue({ + name: queueConfig.name, + sqsConfig: queueConfig.sqsConfig, }); registerQueue(queueConfig.name, queue); diff --git a/packages/worker/src/queue/sqs.ts b/packages/worker/src/queue/sqs.ts new file mode 100644 index 000000000..4647afa3d --- /dev/null +++ b/packages/worker/src/queue/sqs.ts @@ -0,0 +1,99 @@ +import { + DeleteMessageCommand, + Message, + ReceiveMessageCommand, + SendMessageCommand, + SQSClient, +} from "@aws-sdk/client-sqs"; + +import { QueueConfig } from "src/types/queue"; + +import { Queue } from "."; + +class SQSQueue extends Queue { + private config: Required>; + private client: SQSClient; + private queueUrl: string; + private isPooling: boolean = false; + + constructor(config: Required>) { + super(config.name); + + this.config = config; + this.client = new SQSClient(config.sqsConfig.clientConfig); + this.queueUrl = config.sqsConfig.queueUrl; + + this.process(config.sqsConfig.handler); + } + + async process(handler: (data: T) => Promise): Promise { + if (this.isPooling) { + return; + } + + this.isPooling = true; + + const pool = async () => { + while (this.isPooling) { + try { + const command = new ReceiveMessageCommand({ + QueueUrl: this.queueUrl, + MaxNumberOfMessages: this.config.sqsConfig.maxNumberOfMessages, + WaitTimeSeconds: this.config.sqsConfig.waitTimeSeconds, + }); + + const response = await this.client.send(command); + + if (response.Messages && response.Messages.length > 0) { + await Promise.all( + response.Messages.map(async (message: Message) => { + try { + const data = JSON.parse(message.Body!) as T; + + await handler(data); + + await this.client.send( + new DeleteMessageCommand({ + QueueUrl: this.queueUrl, + ReceiptHandle: message.ReceiptHandle, + }), + ); + } catch (error) { + console.error( + `Error processing message from SQS queue: ${this.queueName}. Message ID: ${message.MessageId}. Error: ${(error as Error).message}`, + ); + } + }), + ); + } + } catch (error) { + console.error( + `Error processing job from SQS queue: ${this.queueName}. Error: ${(error as Error).message}`, + ); + } + } + }; + + pool(); + } + + async push(data: T, options?: Record): Promise { + try { + const command = new SendMessageCommand({ + QueueUrl: this.queueUrl, + MessageBody: JSON.stringify(data), + ...options, + }); + + const response = await this.client.send(command); + + return response.MessageId!; + } catch (error) { + throw new Error( + `Failed to push job to SQS queue: ${this.queueName}. Error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export default SQSQueue; diff --git a/packages/worker/src/types/queue.ts b/packages/worker/src/types/queue.ts index a554e6c56..c37cde871 100644 --- a/packages/worker/src/types/queue.ts +++ b/packages/worker/src/types/queue.ts @@ -1,10 +1,12 @@ +import { SQSClientConfig } from "@aws-sdk/client-sqs"; import { RedisOptions, Job } from "bullmq"; import { QueueProvider } from "../enum"; -export interface QueueConfig { +export interface QueueConfig { bullmqConfig?: { connection: RedisOptions; + concurrency?: number; defaultJobOptions?: { attempts?: number; backoff?: { @@ -14,9 +16,15 @@ export interface QueueConfig { removeOnComplete?: boolean | number; removeOnFail?: boolean | number; }; + handler: (job: Job) => Promise; }; - handler: (job: Job) => Promise; - concurrency?: number; name: string; provider: QueueProvider; + sqsConfig?: { + clientConfig: SQSClientConfig; + handler: (data: T) => Promise; + maxNumberOfMessages: number; + waitTimeSeconds: number; + queueUrl: string; + }; } From 970a362b0a4c8919079dc073de722be863fc8923 Mon Sep 17 00:00:00 2001 From: sudeep Date: Thu, 19 Feb 2026 11:08:54 +0545 Subject: [PATCH 08/22] refactor: return queue client --- packages/worker/src/index.ts | 8 +++++--- packages/worker/src/queue/bullmq.ts | 10 +++++++--- packages/worker/src/queue/index.ts | 4 +++- packages/worker/src/queue/sqs.ts | 8 ++++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 7cd985e69..dcc8edda0 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -8,10 +8,12 @@ declare module "@prefabs.tech/fastify-config" { } } -export * from "./enum"; - export { default } from "./plugin"; -export { addToQueue } from "./queue"; +export * from "./enum"; +export * from "./queue"; + +export { SQSClient } from "@aws-sdk/client-sqs"; +export { Job, Queue } from "bullmq"; export type { WorkerConfig } from "./types"; diff --git a/packages/worker/src/queue/bullmq.ts b/packages/worker/src/queue/bullmq.ts index c7e986610..1e9e180a3 100644 --- a/packages/worker/src/queue/bullmq.ts +++ b/packages/worker/src/queue/bullmq.ts @@ -4,9 +4,9 @@ import { QueueConfig } from "../types/queue"; import { Queue } from "."; -class BullMQQueue extends Queue { - private queue: BullQueue; - private worker?: Worker; +class BullMQQueue extends Queue { + public queue: BullQueue; + public worker?: Worker; private connection: RedisOptions; constructor(config: Required>) { @@ -21,6 +21,10 @@ class BullMQQueue extends Queue { this.process(config.bullmqConfig.handler, config.bullmqConfig.concurrency); } + getClient(): BullQueue { + return this.queue; + } + async push(data: T, options?: Record): Promise { try { const job = await this.queue.add(this.queueName, data, options); diff --git a/packages/worker/src/queue/index.ts b/packages/worker/src/queue/index.ts index 317cb1d76..0207d0551 100644 --- a/packages/worker/src/queue/index.ts +++ b/packages/worker/src/queue/index.ts @@ -5,6 +5,8 @@ abstract class Queue { this.queueName = name; } + abstract getClient(): T; + abstract process( handler: (data: T) => Promise, concurrency?: number, @@ -37,4 +39,4 @@ const addToQueue = async ( return queue.push(data, options); }; -export { Queue, registerQueue, addToQueue }; +export { Queue, registerQueue, addToQueue, getQueue }; diff --git a/packages/worker/src/queue/sqs.ts b/packages/worker/src/queue/sqs.ts index 4647afa3d..6c57714eb 100644 --- a/packages/worker/src/queue/sqs.ts +++ b/packages/worker/src/queue/sqs.ts @@ -10,9 +10,9 @@ import { QueueConfig } from "src/types/queue"; import { Queue } from "."; -class SQSQueue extends Queue { +class SQSQueue extends Queue { private config: Required>; - private client: SQSClient; + public client: SQSClient; private queueUrl: string; private isPooling: boolean = false; @@ -26,6 +26,10 @@ class SQSQueue extends Queue { this.process(config.sqsConfig.handler); } + getClient(): SQSClient { + return this.client; + } + async process(handler: (data: T) => Promise): Promise { if (this.isPooling) { return; From dcfb208052cd0058475c3b0a19a27e647c5887b6 Mon Sep 17 00:00:00 2001 From: sudeep Date: Thu, 19 Feb 2026 17:55:23 +0545 Subject: [PATCH 09/22] refactor: update structure for queues --- packages/worker/src/index.ts | 9 ++- packages/worker/src/queue/clients/base.ts | 21 +++++++ .../src/queue/{bullmq.ts => clients/bull.ts} | 9 ++- packages/worker/src/queue/clients/index.ts | 3 + .../worker/src/queue/{ => clients}/sqs.ts | 18 +++--- packages/worker/src/queue/index.ts | 46 ++------------ packages/worker/src/queue/processor.ts | 62 +++++++++++++++++++ packages/worker/src/queue/registry.ts | 30 +++++++++ packages/worker/src/queue/setup.ts | 46 ++------------ packages/worker/src/types/index.ts | 3 + pnpm-lock.yaml | 3 + 11 files changed, 148 insertions(+), 102 deletions(-) create mode 100644 packages/worker/src/queue/clients/base.ts rename packages/worker/src/queue/{bullmq.ts => clients/bull.ts} (92%) create mode 100644 packages/worker/src/queue/clients/index.ts rename packages/worker/src/queue/{ => clients}/sqs.ts (86%) create mode 100644 packages/worker/src/queue/processor.ts create mode 100644 packages/worker/src/queue/registry.ts diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index dcc8edda0..4b4285f6b 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -8,12 +8,11 @@ declare module "@prefabs.tech/fastify-config" { } } +export { SQSClient } from "@aws-sdk/client-sqs"; +export { Job, Queue } from "bullmq"; + export { default } from "./plugin"; export * from "./enum"; export * from "./queue"; - -export { SQSClient } from "@aws-sdk/client-sqs"; -export { Job, Queue } from "bullmq"; - -export type { WorkerConfig } from "./types"; +export * from "./types"; diff --git a/packages/worker/src/queue/clients/base.ts b/packages/worker/src/queue/clients/base.ts new file mode 100644 index 000000000..aa725d966 --- /dev/null +++ b/packages/worker/src/queue/clients/base.ts @@ -0,0 +1,21 @@ +abstract class BaseQueueClient { + public queueName: string; + + constructor(name: string) { + this.queueName = name; + } + + abstract getClient(): Payload; + + abstract process( + handler: (data: Payload) => Promise, + concurrency?: number, + ): void; + + abstract push( + data: Payload, + options?: Record, + ): Promise; +} + +export default BaseQueueClient; diff --git a/packages/worker/src/queue/bullmq.ts b/packages/worker/src/queue/clients/bull.ts similarity index 92% rename from packages/worker/src/queue/bullmq.ts rename to packages/worker/src/queue/clients/bull.ts index 1e9e180a3..ff37f8ca4 100644 --- a/packages/worker/src/queue/bullmq.ts +++ b/packages/worker/src/queue/clients/bull.ts @@ -1,10 +1,9 @@ import { Queue as BullQueue, Worker, Job, RedisOptions } from "bullmq"; -import { QueueConfig } from "../types/queue"; +import BaseQueueClient from "./base"; +import { QueueConfig } from "../../types"; -import { Queue } from "."; - -class BullMQQueue extends Queue { +class BullMqClient extends BaseQueueClient { public queue: BullQueue; public worker?: Worker; private connection: RedisOptions; @@ -69,4 +68,4 @@ class BullMQQueue extends Queue { } } -export default BullMQQueue; +export default BullMqClient; diff --git a/packages/worker/src/queue/clients/index.ts b/packages/worker/src/queue/clients/index.ts new file mode 100644 index 000000000..7ccf0cd53 --- /dev/null +++ b/packages/worker/src/queue/clients/index.ts @@ -0,0 +1,3 @@ +export { default as BaseQueueClient } from "./base"; +export { default as BullMQQueueClient } from "./bull"; +export { default as SQSQueueClient } from "./sqs"; diff --git a/packages/worker/src/queue/sqs.ts b/packages/worker/src/queue/clients/sqs.ts similarity index 86% rename from packages/worker/src/queue/sqs.ts rename to packages/worker/src/queue/clients/sqs.ts index 6c57714eb..94ad99112 100644 --- a/packages/worker/src/queue/sqs.ts +++ b/packages/worker/src/queue/clients/sqs.ts @@ -6,11 +6,10 @@ import { SQSClient, } from "@aws-sdk/client-sqs"; -import { QueueConfig } from "src/types/queue"; +import BaseQueueClient from "./base"; +import { QueueConfig } from "../../types"; -import { Queue } from "."; - -class SQSQueue extends Queue { +class SQSQueueClient extends BaseQueueClient { private config: Required>; public client: SQSClient; private queueUrl: string; @@ -30,7 +29,7 @@ class SQSQueue extends Queue { return this.client; } - async process(handler: (data: T) => Promise): Promise { + async process(handler: (data: Payload) => Promise): Promise { if (this.isPooling) { return; } @@ -52,7 +51,7 @@ class SQSQueue extends Queue { await Promise.all( response.Messages.map(async (message: Message) => { try { - const data = JSON.parse(message.Body!) as T; + const data = JSON.parse(message.Body!) as Payload; await handler(data); @@ -81,7 +80,10 @@ class SQSQueue extends Queue { pool(); } - async push(data: T, options?: Record): Promise { + async push( + data: Payload, + options?: Record, + ): Promise { try { const command = new SendMessageCommand({ QueueUrl: this.queueUrl, @@ -100,4 +102,4 @@ class SQSQueue extends Queue { } } -export default SQSQueue; +export default SQSQueueClient; diff --git a/packages/worker/src/queue/index.ts b/packages/worker/src/queue/index.ts index 0207d0551..52c41df32 100644 --- a/packages/worker/src/queue/index.ts +++ b/packages/worker/src/queue/index.ts @@ -1,42 +1,4 @@ -abstract class Queue { - protected queueName: string; - - constructor(name: string) { - this.queueName = name; - } - - abstract getClient(): T; - - abstract process( - handler: (data: T) => Promise, - concurrency?: number, - ): void; - - abstract push(data: T, options?: Record): Promise; -} - -const queueRegistry = new Map(); - -const getQueue = (name: string): Queue | undefined => { - return queueRegistry.get(name); -}; - -const registerQueue = (name: string, queue: Queue): void => { - queueRegistry.set(name, queue); -}; - -const addToQueue = async ( - queueName: string, - data: T, - options?: Record, -): Promise => { - const queue = getQueue(queueName); - - if (!queue) { - throw new Error(`Queue not found: ${queueName}`); - } - - return queue.push(data, options); -}; - -export { Queue, registerQueue, addToQueue, getQueue }; +export * from "./clients"; +export { default as setupQueues } from "./setup"; +export { default as QueueProcessor } from "./processor"; +export { default as QueueProcessorRegistry } from "./registry"; diff --git a/packages/worker/src/queue/processor.ts b/packages/worker/src/queue/processor.ts new file mode 100644 index 000000000..5714d3c56 --- /dev/null +++ b/packages/worker/src/queue/processor.ts @@ -0,0 +1,62 @@ +import { QueueProvider } from "../enum"; +import { BaseQueueClient, BullMQQueueClient, SQSQueueClient } from "./clients"; +import { QueueConfig } from "../types/queue"; + +class QueueProcessor { + private queueClient: BaseQueueClient; + + constructor(config: QueueConfig) { + this.queueClient = this.initializeQueueClient(config); + } + + protected initializeQueueClient(config: QueueConfig) { + let queueClient: BaseQueueClient; + + switch (config.provider) { + case QueueProvider.BULLMQ: { + if (!config.bullmqConfig) { + throw new Error( + `BullMQ configuration is required for queue: ${config.name}`, + ); + } + + queueClient = new BullMQQueueClient({ + name: config.name, + bullmqConfig: config.bullmqConfig, + }); + + break; + } + + case QueueProvider.SQS: { + if (!config.sqsConfig) { + throw new Error( + `SQS configuration is required for queue: ${config.name}`, + ); + } + + queueClient = new SQSQueueClient({ + name: config.name, + sqsConfig: config.sqsConfig, + }); + + break; + } + default: { + throw new Error(`Unsupported queue provider: ${config.provider}`); + } + } + + return queueClient; + } + + public getQueueClient() { + return this.queueClient; + } + + public getName() { + return this.queueClient.queueName; + } +} + +export default QueueProcessor; diff --git a/packages/worker/src/queue/registry.ts b/packages/worker/src/queue/registry.ts new file mode 100644 index 000000000..acc150bf2 --- /dev/null +++ b/packages/worker/src/queue/registry.ts @@ -0,0 +1,30 @@ +import QueueProcessor from "./processor"; + +class QueueProcessorRegistry { + public static queueProcessors: Map = new Map(); + + public static add(queueProcessor: QueueProcessor) { + QueueProcessorRegistry.queueProcessors.set( + queueProcessor.getName(), + queueProcessor, + ); + } + + public static get(queueName: string): QueueProcessor | undefined { + return QueueProcessorRegistry.queueProcessors.get(queueName); + } + + public static getAll(): QueueProcessor[] { + return [...QueueProcessorRegistry.queueProcessors.values()]; + } + + public static has(queueName: string): boolean { + return QueueProcessorRegistry.queueProcessors.has(queueName); + } + + public static remove(queueName: string): void { + QueueProcessorRegistry.queueProcessors.delete(queueName); + } +} + +export default QueueProcessorRegistry; diff --git a/packages/worker/src/queue/setup.ts b/packages/worker/src/queue/setup.ts index e102999a3..5dc0e60ff 100644 --- a/packages/worker/src/queue/setup.ts +++ b/packages/worker/src/queue/setup.ts @@ -1,9 +1,6 @@ -import { QueueProvider } from "../enum"; import { WorkerConfig } from "../types"; -import BullMQQueue from "./bullmq"; -import SQSQueue from "./sqs"; - -import { registerQueue } from "."; +import QueueProcessor from "./processor"; +import QueueProcessorRegistry from "./registry"; const setupQueues = (config: WorkerConfig) => { if (!config.queues || config.queues.length === 0) { @@ -13,44 +10,9 @@ const setupQueues = (config: WorkerConfig) => { const { queues } = config; for (const queueConfig of queues) { - switch (queueConfig.provider) { - case QueueProvider.BULLMQ: { - if (!queueConfig.bullmqConfig) { - throw new Error( - `BullMQ configuration is required for queue: ${queueConfig.name}`, - ); - } - - const queue = new BullMQQueue({ - name: queueConfig.name, - bullmqConfig: queueConfig.bullmqConfig, - }); - - registerQueue(queueConfig.name, queue); - - break; - } - - case QueueProvider.SQS: { - if (!queueConfig.sqsConfig) { - throw new Error( - `SQS configuration is required for queue: ${queueConfig.name}`, - ); - } - - const queue = new SQSQueue({ - name: queueConfig.name, - sqsConfig: queueConfig.sqsConfig, - }); - - registerQueue(queueConfig.name, queue); + const queueProcessor = new QueueProcessor(queueConfig); - break; - } - default: { - throw new Error(`Unsupported queue provider: ${queueConfig.provider}`); - } - } + QueueProcessorRegistry.add(queueProcessor); } }; diff --git a/packages/worker/src/types/index.ts b/packages/worker/src/types/index.ts index 982a656c5..098c70183 100644 --- a/packages/worker/src/types/index.ts +++ b/packages/worker/src/types/index.ts @@ -5,3 +5,6 @@ export interface WorkerConfig { cronJobs?: CronJob[]; queues?: QueueConfig[]; } + +export * from "./cron"; +export * from "./queue"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8221b45f4..5aac5ddf0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -617,6 +617,9 @@ importers: '@prefabs.tech/fastify-config': specifier: 0.93.5 version: link:../config + '@prefabs.tech/fastify-error-handler': + specifier: 0.93.5 + version: link:../error-handler '@prefabs.tech/tsconfig': specifier: 0.5.0 version: 0.5.0(@types/node@24.10.13) From fd50d5c251756d1b455a8603c3abe2e527a735ba Mon Sep 17 00:00:00 2001 From: sudeep Date: Thu, 19 Feb 2026 17:58:07 +0545 Subject: [PATCH 10/22] chore: update pnpm-lock --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5aac5ddf0..8221b45f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -617,9 +617,6 @@ importers: '@prefabs.tech/fastify-config': specifier: 0.93.5 version: link:../config - '@prefabs.tech/fastify-error-handler': - specifier: 0.93.5 - version: link:../error-handler '@prefabs.tech/tsconfig': specifier: 0.5.0 version: 0.5.0(@types/node@24.10.13) From 3eee54f87fd182ada847c8f7335146245b8d94fe Mon Sep 17 00:00:00 2001 From: sudeep Date: Thu, 19 Feb 2026 18:07:25 +0545 Subject: [PATCH 11/22] chore: add readme for worker package --- packages/worker/README.md | 141 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/packages/worker/README.md b/packages/worker/README.md index 17b34f39f..dc74acc42 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -1 +1,142 @@ # @prefabs.tech/fastify-worker + +A [Fastify](https://github.com/fastify/fastify) plugin for managing queue processes and cron tasks. It provides a unified interface for working with queues (BullMQ, SQS) and scheduling recurring tasks. + +## Features + +- **Cron Jobs**: Schedule recurring tasks using standard cron expressions +- **Queue System**: Basic queue management with support for BullMQ and AWS SQS +- **BullMQ Integration**: Redis-based message queues for high-performance background processing +- **AWS SQS Integration**: Support for Amazon Simple Queue Service + +## Requirements + +- [@prefabs.tech/fastify-config](https://www.npmjs.com/package/@prefabs.tech/fastify-config) + +## Usage + +### Register Plugin + +Register the worker plugin with your Fastify instance: + +```typescript +import workerPlugin from "@prefabs.tech/fastify-worker"; +import configPlugin from "@prefabs.tech/fastify-config"; +import Fastify from "fastify"; + +import config from "./config"; + +const start = async () => { + // Create fastify instance + const fastify = Fastify({ + logger: config.logger, + }); + + // Register fastify-config plugin + await fastify.register(configPlugin, { config }); + + // Register worker plugin + await fastify.register(workerPlugin); + + await fastify.listen({ + port: config.port, + host: "0.0.0.0", + }); +}; + +start(); +``` + +## Configuration + +Add worker configuration to your config: + +```typescript +import { QueueProvider } from "@prefabs.tech/fastify-worker"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + // ...other config + worker: { + cronJobs: [ + { + expression: "0 0 * * *", // Run daily at midnight + task: async () => { + console.log("Running daily cleanup..."); + }, + options: { + scheduled: true, + timezone: "UTC", + }, + }, + ], + queues: [ + { + name: "email-queue", + provider: QueueProvider.BULLMQ, + bullmqConfig: { + connection: { + host: "localhost", + port: 6379, + }, + concurrency: 5, + defaultJobOptions: { + attempts: 3, + backoff: { + type: "exponential", + delay: 1000, + }, + }, + handler: async (job) => { + console.log(`Processing email job ${job.id}`); + // Send email logic here + }, + }, + }, + { + name: "audit-log-queue", + provider: QueueProvider.SQS, + sqsConfig: { + clientConfig: { + region: "us-east-1", + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + }, + queueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/audit-logs", + maxNumberOfMessages: 10, + waitTimeSeconds: 20, + handler: async (message) => { + console.log("Processing audit log", message); + }, + }, + }, + ], + }, +}; +``` + +## Adding Jobs to Queues + +To add jobs to a registered queue, use the `QueueProcessorRegistry` to access the queue client: + +```typescript +import { QueueProcessorRegistry } from "@prefabs.tech/fastify-worker"; + +// Get the processor for a specific queue +const processor = QueueProcessorRegistry.get("email-queue"); + +if (processor) { + // Add a job to the queue + await processor.getQueueClient().push({ + to: "user@example.com", + subject: "Welcome!", + body: "Hello from Fastify Worker", + }); + + console.log("Job added to email-queue"); +} else { + console.error("Queue not found"); +} +``` From 2f81b91c458472f5cb3d7313518c7111922a0e3d Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 20 Feb 2026 09:33:06 +0545 Subject: [PATCH 12/22] refactor: update queue error handling and config --- packages/worker/README.md | 4 +-- packages/worker/src/queue/clients/bull.ts | 35 ++++++++++++++++------- packages/worker/src/queue/clients/sqs.ts | 19 +++++++----- packages/worker/src/types/queue.ts | 13 +++++---- 4 files changed, 46 insertions(+), 25 deletions(-) diff --git a/packages/worker/README.md b/packages/worker/README.md index dc74acc42..e37a5e449 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -105,8 +105,8 @@ const config: ApiConfig = { }, }, queueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/audit-logs", - maxNumberOfMessages: 10, - waitTimeSeconds: 20, + maxNumberOfMessages: 10, // Defines maximum number of messages SQS ReceiveMessage action will return in a single call. Default: 10 + waitTimeSeconds: 20, // Defines how long a ReceiveMessage API call waits for a message to arrive before returning. Default: 0 handler: async (message) => { console.log("Processing audit log", message); }, diff --git a/packages/worker/src/queue/clients/bull.ts b/packages/worker/src/queue/clients/bull.ts index ff37f8ca4..700ad2fd1 100644 --- a/packages/worker/src/queue/clients/bull.ts +++ b/packages/worker/src/queue/clients/bull.ts @@ -3,7 +3,7 @@ import { Queue as BullQueue, Worker, Job, RedisOptions } from "bullmq"; import BaseQueueClient from "./base"; import { QueueConfig } from "../../types"; -class BullMqClient extends BaseQueueClient { +class BullMqClient extends BaseQueueClient { public queue: BullQueue; public worker?: Worker; private connection: RedisOptions; @@ -17,14 +17,22 @@ class BullMqClient extends BaseQueueClient { defaultJobOptions: config.bullmqConfig.defaultJobOptions, }); - this.process(config.bullmqConfig.handler, config.bullmqConfig.concurrency); + this.process( + config.bullmqConfig.handler, + config.bullmqConfig.concurrency, + config.bullmqConfig.onError, + config.bullmqConfig.onFailed, + ); } getClient(): BullQueue { return this.queue; } - async push(data: T, options?: Record): Promise { + async push( + data: Payload, + options?: Record, + ): Promise { try { const job = await this.queue.add(this.queueName, data, options); @@ -36,11 +44,16 @@ class BullMqClient extends BaseQueueClient { } } - process(handler: (job: Job) => Promise, concurrency = 1): void { + process( + handler: (job: Job) => Promise, + concurrency = 1, + onError?: (error: Error) => void, + onFailed?: (job: Job, error: Error) => void, + ): void { try { this.worker = new Worker( this.queueName, - async (job: Job) => { + async (job: Job) => { await handler(job); }, { @@ -50,15 +63,15 @@ class BullMqClient extends BaseQueueClient { ); this.worker.on("error", (error) => { - console.error( - `Error in BullMQ worker for queue: ${this.queueName}. Error: ${(error as Error).message}`, - ); + if (onError) { + onError(error); + } }); this.worker.on("failed", (job, error) => { - console.error( - `Job failed in BullMQ queue: ${this.queueName}. Job ID: ${job?.id}. Error: ${(error as Error).message}`, - ); + if (onFailed) { + onFailed(job as Job, error); + } }); } catch (error) { throw new Error( diff --git a/packages/worker/src/queue/clients/sqs.ts b/packages/worker/src/queue/clients/sqs.ts index 94ad99112..a1ca02209 100644 --- a/packages/worker/src/queue/clients/sqs.ts +++ b/packages/worker/src/queue/clients/sqs.ts @@ -51,7 +51,7 @@ class SQSQueueClient extends BaseQueueClient { await Promise.all( response.Messages.map(async (message: Message) => { try { - const data = JSON.parse(message.Body!) as Payload; + const data = JSON.parse(message.Body ?? "{}") as Payload; await handler(data); @@ -62,17 +62,22 @@ class SQSQueueClient extends BaseQueueClient { }), ); } catch (error) { - console.error( - `Error processing message from SQS queue: ${this.queueName}. Message ID: ${message.MessageId}. Error: ${(error as Error).message}`, - ); + if (this.config.sqsConfig.onError) { + this.config.sqsConfig.onError( + error instanceof Error ? error : new Error(String(error)), + message, + ); + } } }), ); } } catch (error) { - console.error( - `Error processing job from SQS queue: ${this.queueName}. Error: ${(error as Error).message}`, - ); + if (this.config.sqsConfig.onError) { + this.config.sqsConfig.onError( + error instanceof Error ? error : new Error(String(error)), + ); + } } } }; diff --git a/packages/worker/src/types/queue.ts b/packages/worker/src/types/queue.ts index c37cde871..d9fe3e4de 100644 --- a/packages/worker/src/types/queue.ts +++ b/packages/worker/src/types/queue.ts @@ -1,30 +1,33 @@ -import { SQSClientConfig } from "@aws-sdk/client-sqs"; +import { Message, SQSClientConfig } from "@aws-sdk/client-sqs"; import { RedisOptions, Job } from "bullmq"; import { QueueProvider } from "../enum"; export interface QueueConfig { bullmqConfig?: { - connection: RedisOptions; concurrency?: number; + connection: RedisOptions; defaultJobOptions?: { attempts?: number; backoff?: { - type: string; delay: number; + type: string; }; removeOnComplete?: boolean | number; removeOnFail?: boolean | number; }; handler: (job: Job) => Promise; + onError?: (error: Error) => void; + onFailed?: (job: Job, error: Error) => void; }; name: string; provider: QueueProvider; sqsConfig?: { clientConfig: SQSClientConfig; handler: (data: T) => Promise; - maxNumberOfMessages: number; - waitTimeSeconds: number; + maxNumberOfMessages?: number; + onError?: (error: Error, message?: Message) => void; queueUrl: string; + waitTimeSeconds?: number; }; } From 7df2d60b9e472df550d495f8cb4828ca38a662c9 Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 20 Feb 2026 10:33:47 +0545 Subject: [PATCH 13/22] feat: add ability to initialize queue outside plugin --- packages/worker/src/cron/index.ts | 2 +- packages/worker/src/cron/setup.ts | 8 +++----- packages/worker/src/index.ts | 1 + packages/worker/src/lib/index.ts | 1 + packages/worker/src/lib/initialize.ts | 17 +++++++++++++++++ packages/worker/src/plugin.ts | 15 ++++++--------- packages/worker/src/queue/index.ts | 3 ++- packages/worker/src/queue/setup.ts | 12 +++++------- 8 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 packages/worker/src/lib/index.ts create mode 100644 packages/worker/src/lib/initialize.ts diff --git a/packages/worker/src/cron/index.ts b/packages/worker/src/cron/index.ts index dcdc3e6c7..503f327dc 100644 --- a/packages/worker/src/cron/index.ts +++ b/packages/worker/src/cron/index.ts @@ -1 +1 @@ -export * from "./setup"; +export { default as setupCronJobs } from "./setup"; diff --git a/packages/worker/src/cron/setup.ts b/packages/worker/src/cron/setup.ts index ff1a67dfe..e1199497f 100644 --- a/packages/worker/src/cron/setup.ts +++ b/packages/worker/src/cron/setup.ts @@ -1,14 +1,12 @@ import cron from "node-cron"; -import { WorkerConfig } from "src/types"; +import { CronJob } from "src/types"; -const setupCronJobs = (config: WorkerConfig) => { - if (!config.cronJobs || config.cronJobs.length === 0) { +const setupCronJobs = (cronJobs: CronJob[]) => { + if (cronJobs.length === 0) { return; } - const { cronJobs } = config; - for (const job of cronJobs) { cron.schedule(job.expression, job.task, job.options); } diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 4b4285f6b..d287ce467 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -14,5 +14,6 @@ export { Job, Queue } from "bullmq"; export { default } from "./plugin"; export * from "./enum"; +export * from "./lib"; export * from "./queue"; export * from "./types"; diff --git a/packages/worker/src/lib/index.ts b/packages/worker/src/lib/index.ts new file mode 100644 index 000000000..048cfe5bb --- /dev/null +++ b/packages/worker/src/lib/index.ts @@ -0,0 +1 @@ +export * from "./initialize"; diff --git a/packages/worker/src/lib/initialize.ts b/packages/worker/src/lib/initialize.ts new file mode 100644 index 000000000..36ade4bd8 --- /dev/null +++ b/packages/worker/src/lib/initialize.ts @@ -0,0 +1,17 @@ +import { setupCronJobs } from "../cron"; +import { setupQueueProcessors } from "../queue"; +import { CronJob, QueueConfig } from "../types"; + +const initializeCronJobs = async (cronConfigs?: CronJob[]) => { + if (!cronConfigs) return; + + setupCronJobs(cronConfigs); +}; + +const initializeQueueProcessors = async (queueConfigs?: QueueConfig[]) => { + if (!queueConfigs) return; + + setupQueueProcessors(queueConfigs); +}; + +export { initializeCronJobs, initializeQueueProcessors }; diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index 96e82b160..8e20d7333 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -1,8 +1,10 @@ import { FastifyInstance } from "fastify"; import FastifyPlugin from "fastify-plugin"; -import setupCronJobs from "./cron/setup"; -import setupQueues from "./queue/setup"; +import { + initializeCronJobs, + initializeQueueProcessors, +} from "./lib/initialize"; const plugin = async (fastify: FastifyInstance) => { const { config, log } = fastify; @@ -15,13 +17,8 @@ const plugin = async (fastify: FastifyInstance) => { log.info("Registering worker plugin"); - if (config.worker.cronJobs) { - setupCronJobs(config.worker); - } - - if (config.worker.queues) { - setupQueues(config.worker); - } + initializeCronJobs(config.worker.cronJobs); + initializeQueueProcessors(config.worker.queues); }; export default FastifyPlugin(plugin); diff --git a/packages/worker/src/queue/index.ts b/packages/worker/src/queue/index.ts index 52c41df32..4b887b5e2 100644 --- a/packages/worker/src/queue/index.ts +++ b/packages/worker/src/queue/index.ts @@ -1,4 +1,5 @@ export * from "./clients"; -export { default as setupQueues } from "./setup"; + +export { default as setupQueueProcessors } from "./setup"; export { default as QueueProcessor } from "./processor"; export { default as QueueProcessorRegistry } from "./registry"; diff --git a/packages/worker/src/queue/setup.ts b/packages/worker/src/queue/setup.ts index 5dc0e60ff..ae7cdabb0 100644 --- a/packages/worker/src/queue/setup.ts +++ b/packages/worker/src/queue/setup.ts @@ -1,19 +1,17 @@ -import { WorkerConfig } from "../types"; import QueueProcessor from "./processor"; import QueueProcessorRegistry from "./registry"; +import { QueueConfig } from "../types"; -const setupQueues = (config: WorkerConfig) => { - if (!config.queues || config.queues.length === 0) { +const setupQueueProcessors = (queueConfigs: QueueConfig[]) => { + if (queueConfigs.length === 0) { return; } - const { queues } = config; - - for (const queueConfig of queues) { + for (const queueConfig of queueConfigs) { const queueProcessor = new QueueProcessor(queueConfig); QueueProcessorRegistry.add(queueProcessor); } }; -export default setupQueues; +export default setupQueueProcessors; From b0df3eca50e6efc6bb383c629384f71f66b29abc Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 20 Feb 2026 12:27:35 +0545 Subject: [PATCH 14/22] refactor: update queue client type --- packages/worker/src/queue/clients/bull.ts | 67 ++++++++++++----------- packages/worker/src/queue/clients/sqs.ts | 34 +++++++----- packages/worker/src/queue/processor.ts | 10 +--- packages/worker/src/types/queue.ts | 33 ++--------- 4 files changed, 64 insertions(+), 80 deletions(-) diff --git a/packages/worker/src/queue/clients/bull.ts b/packages/worker/src/queue/clients/bull.ts index 700ad2fd1..24b6d3d5a 100644 --- a/packages/worker/src/queue/clients/bull.ts +++ b/packages/worker/src/queue/clients/bull.ts @@ -1,28 +1,41 @@ -import { Queue as BullQueue, Worker, Job, RedisOptions } from "bullmq"; +import { + Queue as BullQueue, + Worker, + Job, + QueueOptions, + WorkerOptions, +} from "bullmq"; import BaseQueueClient from "./base"; -import { QueueConfig } from "../../types"; + +export interface BullMQClientConfig { + queueOptions: QueueOptions; + workerOptions?: WorkerOptions; + handler: (job: Job) => Promise; + onError?: (error: Error) => void; + onFailed?: (job: Job, error: Error) => void; +} class BullMqClient extends BaseQueueClient { public queue: BullQueue; public worker?: Worker; - private connection: RedisOptions; + private queueOptions: QueueOptions; + private workerOptions?: WorkerOptions; + private handler: (job: Job) => Promise; + private onError?: (error: Error) => void; + private onFailed?: (job: Job, error: Error) => void; - constructor(config: Required>) { - super(config.name); + constructor(name: string, config: BullMQClientConfig) { + super(name); - this.connection = config.bullmqConfig.connection; - this.queue = new BullQueue(this.queueName, { - connection: this.connection, - defaultJobOptions: config.bullmqConfig.defaultJobOptions, - }); + this.queueOptions = config.queueOptions; + this.workerOptions = config.workerOptions; + this.handler = config.handler; + this.onError = config.onError; + this.onFailed = config.onFailed; - this.process( - config.bullmqConfig.handler, - config.bullmqConfig.concurrency, - config.bullmqConfig.onError, - config.bullmqConfig.onFailed, - ); + this.queue = new BullQueue(this.queueName, this.queueOptions); + this.process(); } getClient(): BullQueue { @@ -44,33 +57,25 @@ class BullMqClient extends BaseQueueClient { } } - process( - handler: (job: Job) => Promise, - concurrency = 1, - onError?: (error: Error) => void, - onFailed?: (job: Job, error: Error) => void, - ): void { + process(): void { try { this.worker = new Worker( this.queueName, async (job: Job) => { - await handler(job); - }, - { - connection: this.connection, - concurrency, + await this.handler(job); }, + this.workerOptions, ); this.worker.on("error", (error) => { - if (onError) { - onError(error); + if (this.onError) { + this.onError(error); } }); this.worker.on("failed", (job, error) => { - if (onFailed) { - onFailed(job as Job, error); + if (this.onFailed) { + this.onFailed(job as Job, error); } }); } catch (error) { diff --git a/packages/worker/src/queue/clients/sqs.ts b/packages/worker/src/queue/clients/sqs.ts index a1ca02209..7094e8841 100644 --- a/packages/worker/src/queue/clients/sqs.ts +++ b/packages/worker/src/queue/clients/sqs.ts @@ -2,27 +2,36 @@ import { DeleteMessageCommand, Message, ReceiveMessageCommand, + ReceiveMessageCommandInput, SendMessageCommand, SQSClient, + SQSClientConfig, } from "@aws-sdk/client-sqs"; import BaseQueueClient from "./base"; -import { QueueConfig } from "../../types"; + +export interface SQSQueueClientConfig { + clientConfig: SQSClientConfig; + handler: (data: unknown) => Promise; + receiveMessageOptions?: ReceiveMessageCommandInput; + onError?: (error: Error, message?: Message) => void; + queueUrl: string; +} class SQSQueueClient extends BaseQueueClient { - private config: Required>; + private config: SQSQueueClientConfig; public client: SQSClient; private queueUrl: string; private isPooling: boolean = false; - constructor(config: Required>) { - super(config.name); + constructor(name: string, config: SQSQueueClientConfig) { + super(name); this.config = config; - this.client = new SQSClient(config.sqsConfig.clientConfig); - this.queueUrl = config.sqsConfig.queueUrl; + this.client = new SQSClient(config.clientConfig); + this.queueUrl = config.queueUrl; - this.process(config.sqsConfig.handler); + this.process(config.handler); } getClient(): SQSClient { @@ -41,8 +50,7 @@ class SQSQueueClient extends BaseQueueClient { try { const command = new ReceiveMessageCommand({ QueueUrl: this.queueUrl, - MaxNumberOfMessages: this.config.sqsConfig.maxNumberOfMessages, - WaitTimeSeconds: this.config.sqsConfig.waitTimeSeconds, + ...this.config.receiveMessageOptions, }); const response = await this.client.send(command); @@ -62,8 +70,8 @@ class SQSQueueClient extends BaseQueueClient { }), ); } catch (error) { - if (this.config.sqsConfig.onError) { - this.config.sqsConfig.onError( + if (this.config.onError) { + this.config.onError( error instanceof Error ? error : new Error(String(error)), message, ); @@ -73,8 +81,8 @@ class SQSQueueClient extends BaseQueueClient { ); } } catch (error) { - if (this.config.sqsConfig.onError) { - this.config.sqsConfig.onError( + if (this.config.onError) { + this.config.onError( error instanceof Error ? error : new Error(String(error)), ); } diff --git a/packages/worker/src/queue/processor.ts b/packages/worker/src/queue/processor.ts index 5714d3c56..0da9efb16 100644 --- a/packages/worker/src/queue/processor.ts +++ b/packages/worker/src/queue/processor.ts @@ -20,10 +20,7 @@ class QueueProcessor { ); } - queueClient = new BullMQQueueClient({ - name: config.name, - bullmqConfig: config.bullmqConfig, - }); + queueClient = new BullMQQueueClient(config.name, config.bullmqConfig); break; } @@ -35,10 +32,7 @@ class QueueProcessor { ); } - queueClient = new SQSQueueClient({ - name: config.name, - sqsConfig: config.sqsConfig, - }); + queueClient = new SQSQueueClient(config.name, config.sqsConfig); break; } diff --git a/packages/worker/src/types/queue.ts b/packages/worker/src/types/queue.ts index d9fe3e4de..623bf27e8 100644 --- a/packages/worker/src/types/queue.ts +++ b/packages/worker/src/types/queue.ts @@ -1,33 +1,10 @@ -import { Message, SQSClientConfig } from "@aws-sdk/client-sqs"; -import { RedisOptions, Job } from "bullmq"; - import { QueueProvider } from "../enum"; +import { BullMQClientConfig } from "../queue/clients/bull"; +import { SQSQueueClientConfig } from "../queue/clients/sqs"; -export interface QueueConfig { - bullmqConfig?: { - concurrency?: number; - connection: RedisOptions; - defaultJobOptions?: { - attempts?: number; - backoff?: { - delay: number; - type: string; - }; - removeOnComplete?: boolean | number; - removeOnFail?: boolean | number; - }; - handler: (job: Job) => Promise; - onError?: (error: Error) => void; - onFailed?: (job: Job, error: Error) => void; - }; +export interface QueueConfig { + bullmqConfig?: BullMQClientConfig; name: string; provider: QueueProvider; - sqsConfig?: { - clientConfig: SQSClientConfig; - handler: (data: T) => Promise; - maxNumberOfMessages?: number; - onError?: (error: Error, message?: Message) => void; - queueUrl: string; - waitTimeSeconds?: number; - }; + sqsConfig?: SQSQueueClientConfig; } From fab8a3947bfd75cacb188e6c5dd1a2db4b5c8835 Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 20 Feb 2026 13:12:43 +0545 Subject: [PATCH 15/22] chore: update readme --- packages/worker/README.md | 38 +++++++++-------------- packages/worker/src/queue/clients/bull.ts | 6 ++-- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/packages/worker/README.md b/packages/worker/README.md index e37a5e449..70c19e983 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -72,44 +72,36 @@ const config: ApiConfig = { ], queues: [ { - name: "email-queue", + name: "bull-queue", provider: QueueProvider.BULLMQ, bullmqConfig: { - connection: { - host: "localhost", - port: 6379, + handler: async (job) => { + // }, - concurrency: 5, - defaultJobOptions: { - attempts: 3, - backoff: { - type: "exponential", - delay: 1000, + queueOptions: { + connection: { + host: "localhost", + port: 6379, }, }, - handler: async (job) => { - console.log(`Processing email job ${job.id}`); - // Send email logic here - }, }, }, { - name: "audit-log-queue", + name: "sqs-queue", provider: QueueProvider.SQS, sqsConfig: { clientConfig: { - region: "us-east-1", credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID!, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + accessKeyId: "", + secretAccessKey: "", }, + endpoint: "", + region: "", }, - queueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/audit-logs", - maxNumberOfMessages: 10, // Defines maximum number of messages SQS ReceiveMessage action will return in a single call. Default: 10 - waitTimeSeconds: 20, // Defines how long a ReceiveMessage API call waits for a message to arrive before returning. Default: 0 - handler: async (message) => { - console.log("Processing audit log", message); + handler: async (message: any) => { + // }, + queueUrl: "", }, }, ], diff --git a/packages/worker/src/queue/clients/bull.ts b/packages/worker/src/queue/clients/bull.ts index 24b6d3d5a..bc509e001 100644 --- a/packages/worker/src/queue/clients/bull.ts +++ b/packages/worker/src/queue/clients/bull.ts @@ -29,11 +29,13 @@ class BullMqClient extends BaseQueueClient { super(name); this.queueOptions = config.queueOptions; - this.workerOptions = config.workerOptions; + this.workerOptions = { + connection: config.queueOptions.connection, + ...config.workerOptions, + }; this.handler = config.handler; this.onError = config.onError; this.onFailed = config.onFailed; - this.queue = new BullQueue(this.queueName, this.queueOptions); this.process(); } From eb4f9129d81cfa7a1926065d7f306c79b8d7f9fa Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 20 Feb 2026 13:21:33 +0545 Subject: [PATCH 16/22] fix: update bull mq job type --- packages/worker/src/queue/clients/bull.ts | 10 ++++------ packages/worker/src/queue/clients/sqs.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/worker/src/queue/clients/bull.ts b/packages/worker/src/queue/clients/bull.ts index bc509e001..a62c4290d 100644 --- a/packages/worker/src/queue/clients/bull.ts +++ b/packages/worker/src/queue/clients/bull.ts @@ -4,6 +4,7 @@ import { Job, QueueOptions, WorkerOptions, + JobsOptions, } from "bullmq"; import BaseQueueClient from "./base"; @@ -19,11 +20,11 @@ export interface BullMQClientConfig { class BullMqClient extends BaseQueueClient { public queue: BullQueue; public worker?: Worker; - private queueOptions: QueueOptions; - private workerOptions?: WorkerOptions; private handler: (job: Job) => Promise; private onError?: (error: Error) => void; private onFailed?: (job: Job, error: Error) => void; + private queueOptions: QueueOptions; + private workerOptions?: WorkerOptions; constructor(name: string, config: BullMQClientConfig) { super(name); @@ -44,10 +45,7 @@ class BullMqClient extends BaseQueueClient { return this.queue; } - async push( - data: Payload, - options?: Record, - ): Promise { + async push(data: Payload, options?: JobsOptions): Promise { try { const job = await this.queue.add(this.queueName, data, options); diff --git a/packages/worker/src/queue/clients/sqs.ts b/packages/worker/src/queue/clients/sqs.ts index 7094e8841..6268f1cf3 100644 --- a/packages/worker/src/queue/clients/sqs.ts +++ b/packages/worker/src/queue/clients/sqs.ts @@ -13,9 +13,9 @@ import BaseQueueClient from "./base"; export interface SQSQueueClientConfig { clientConfig: SQSClientConfig; handler: (data: unknown) => Promise; - receiveMessageOptions?: ReceiveMessageCommandInput; onError?: (error: Error, message?: Message) => void; queueUrl: string; + receiveMessageOptions?: ReceiveMessageCommandInput; } class SQSQueueClient extends BaseQueueClient { From bff48aa0b42383952f43f746dc25750201547ea2 Mon Sep 17 00:00:00 2001 From: sudeep Date: Fri, 20 Feb 2026 15:22:31 +0545 Subject: [PATCH 17/22] chore: pin node-cron --- packages/worker/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/worker/package.json b/packages/worker/package.json index 287a15487..44f3dba7b 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -32,7 +32,7 @@ "dependencies": { "@aws-sdk/client-sqs": "3.991.0", "bullmq": "5.69.3", - "node-cron": "^4.2.1", + "node-cron": "4.2.1", "zod": "3.25.76" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8221b45f4..a128911c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -605,7 +605,7 @@ importers: specifier: 5.69.3 version: 5.69.3 node-cron: - specifier: ^4.2.1 + specifier: 4.2.1 version: 4.2.1 zod: specifier: 3.25.76 From a9a90562fc96ad43b5f7dd904a6e20ff7dedc6e5 Mon Sep 17 00:00:00 2001 From: Sudeep Bashistha <32100152+BashisthaSudeep@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:38:05 +0545 Subject: [PATCH 18/22] refactor: update queue implementation (#1069) --- packages/worker/README.md | 77 ++++++------ packages/worker/src/cron/index.ts | 2 +- packages/worker/src/cron/scheduler.ts | 23 ++++ packages/worker/src/cron/setup.ts | 15 --- packages/worker/src/index.ts | 2 +- packages/worker/src/lib/index.ts | 1 - packages/worker/src/lib/initialize.ts | 17 --- packages/worker/src/plugin.ts | 17 ++- packages/worker/src/queue/adapterRegistry.ts | 35 ++++++ packages/worker/src/queue/adapters/base.ts | 17 +++ packages/worker/src/queue/adapters/bullmq.ts | 83 +++++++++++++ packages/worker/src/queue/adapters/index.ts | 6 + packages/worker/src/queue/adapters/sqs.ts | 124 +++++++++++++++++++ packages/worker/src/queue/clients/base.ts | 21 ---- packages/worker/src/queue/clients/bull.ts | 89 ------------- packages/worker/src/queue/clients/index.ts | 3 - packages/worker/src/queue/clients/sqs.ts | 118 ------------------ packages/worker/src/queue/factory.ts | 33 +++++ packages/worker/src/queue/index.ts | 7 +- packages/worker/src/queue/processor.ts | 56 --------- packages/worker/src/queue/registry.ts | 30 ----- packages/worker/src/queue/setup.ts | 17 --- packages/worker/src/types/queue.ts | 8 +- packages/worker/src/worker.ts | 38 ++++++ 24 files changed, 421 insertions(+), 418 deletions(-) create mode 100644 packages/worker/src/cron/scheduler.ts delete mode 100644 packages/worker/src/cron/setup.ts delete mode 100644 packages/worker/src/lib/index.ts delete mode 100644 packages/worker/src/lib/initialize.ts create mode 100644 packages/worker/src/queue/adapterRegistry.ts create mode 100644 packages/worker/src/queue/adapters/base.ts create mode 100644 packages/worker/src/queue/adapters/bullmq.ts create mode 100644 packages/worker/src/queue/adapters/index.ts create mode 100644 packages/worker/src/queue/adapters/sqs.ts delete mode 100644 packages/worker/src/queue/clients/base.ts delete mode 100644 packages/worker/src/queue/clients/bull.ts delete mode 100644 packages/worker/src/queue/clients/index.ts delete mode 100644 packages/worker/src/queue/clients/sqs.ts create mode 100644 packages/worker/src/queue/factory.ts delete mode 100644 packages/worker/src/queue/processor.ts delete mode 100644 packages/worker/src/queue/registry.ts delete mode 100644 packages/worker/src/queue/setup.ts create mode 100644 packages/worker/src/worker.ts diff --git a/packages/worker/README.md b/packages/worker/README.md index 70c19e983..618384998 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -5,37 +5,30 @@ A [Fastify](https://github.com/fastify/fastify) plugin for managing queue proces ## Features - **Cron Jobs**: Schedule recurring tasks using standard cron expressions -- **Queue System**: Basic queue management with support for BullMQ and AWS SQS +- **Queue System**: Queue management with support for BullMQ and AWS SQS - **BullMQ Integration**: Redis-based message queues for high-performance background processing - **AWS SQS Integration**: Support for Amazon Simple Queue Service ## Requirements - - [@prefabs.tech/fastify-config](https://www.npmjs.com/package/@prefabs.tech/fastify-config) ## Usage -### Register Plugin +### Fastify Plugin Register the worker plugin with your Fastify instance: ```typescript import workerPlugin from "@prefabs.tech/fastify-worker"; -import configPlugin from "@prefabs.tech/fastify-config"; import Fastify from "fastify"; import config from "./config"; const start = async () => { - // Create fastify instance const fastify = Fastify({ logger: config.logger, }); - // Register fastify-config plugin - await fastify.register(configPlugin, { config }); - - // Register worker plugin await fastify.register(workerPlugin); await fastify.listen({ @@ -47,6 +40,44 @@ const start = async () => { start(); ``` +### Pushing to the queue + +The `AdapterRegistry` is a singleton. Once the plugin initializes the worker, any service can access the same registry directly — no instance passing required: + +```typescript +await fastify.register(workerPlugin); +``` + +```typescript +import { Worker } from "@prefabs.tech/fastify-worker"; + +const queue = Worker.adapters.get("queue-name") + +if (queue) { + queue.push({ message: 'Hello world!' }) +} +``` + +The plugin creates the `Worker` instance, which populates `Worker.adapters` on `start()`. Services import `Worker` and access the static registry directly. On fastify close, `worker.shutdown()` drains all adapters. + +### Standalone + +Use the `Worker` class directly without Fastify: + +```typescript +import { Worker } from "@prefabs.tech/fastify-worker"; + +const worker = new Worker({ + cronJobs: [...], + queues: [...], +}); + +await worker.start(); + +// later... +await worker.shutdown(); +``` + ## Configuration Add worker configuration to your config: @@ -60,7 +91,7 @@ const config: ApiConfig = { worker: { cronJobs: [ { - expression: "0 0 * * *", // Run daily at midnight + expression: "0 0 * * *", task: async () => { console.log("Running daily cleanup..."); }, @@ -98,7 +129,7 @@ const config: ApiConfig = { endpoint: "", region: "", }, - handler: async (message: any) => { + handler: async (message) => { // }, queueUrl: "", @@ -108,27 +139,3 @@ const config: ApiConfig = { }, }; ``` - -## Adding Jobs to Queues - -To add jobs to a registered queue, use the `QueueProcessorRegistry` to access the queue client: - -```typescript -import { QueueProcessorRegistry } from "@prefabs.tech/fastify-worker"; - -// Get the processor for a specific queue -const processor = QueueProcessorRegistry.get("email-queue"); - -if (processor) { - // Add a job to the queue - await processor.getQueueClient().push({ - to: "user@example.com", - subject: "Welcome!", - body: "Hello from Fastify Worker", - }); - - console.log("Job added to email-queue"); -} else { - console.error("Queue not found"); -} -``` diff --git a/packages/worker/src/cron/index.ts b/packages/worker/src/cron/index.ts index 503f327dc..d11e20550 100644 --- a/packages/worker/src/cron/index.ts +++ b/packages/worker/src/cron/index.ts @@ -1 +1 @@ -export { default as setupCronJobs } from "./setup"; +export { default as CronScheduler } from "./scheduler"; diff --git a/packages/worker/src/cron/scheduler.ts b/packages/worker/src/cron/scheduler.ts new file mode 100644 index 000000000..9fd3d13ec --- /dev/null +++ b/packages/worker/src/cron/scheduler.ts @@ -0,0 +1,23 @@ +import cron, { ScheduledTask } from "node-cron"; + +import { CronJob } from "../types"; + +class CronScheduler { + private tasks: ScheduledTask[] = []; + + schedule(job: CronJob): void { + const task = cron.schedule(job.expression, job.task, job.options); + + this.tasks.push(task); + } + + stopAll(): void { + for (const task of this.tasks) { + task.stop(); + } + + this.tasks = []; + } +} + +export default CronScheduler; diff --git a/packages/worker/src/cron/setup.ts b/packages/worker/src/cron/setup.ts deleted file mode 100644 index e1199497f..000000000 --- a/packages/worker/src/cron/setup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import cron from "node-cron"; - -import { CronJob } from "src/types"; - -const setupCronJobs = (cronJobs: CronJob[]) => { - if (cronJobs.length === 0) { - return; - } - - for (const job of cronJobs) { - cron.schedule(job.expression, job.task, job.options); - } -}; - -export default setupCronJobs; diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index d287ce467..eda4a09f4 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -12,8 +12,8 @@ export { SQSClient } from "@aws-sdk/client-sqs"; export { Job, Queue } from "bullmq"; export { default } from "./plugin"; +export { default as Worker } from "./worker"; export * from "./enum"; -export * from "./lib"; export * from "./queue"; export * from "./types"; diff --git a/packages/worker/src/lib/index.ts b/packages/worker/src/lib/index.ts deleted file mode 100644 index 048cfe5bb..000000000 --- a/packages/worker/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./initialize"; diff --git a/packages/worker/src/lib/initialize.ts b/packages/worker/src/lib/initialize.ts deleted file mode 100644 index 36ade4bd8..000000000 --- a/packages/worker/src/lib/initialize.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { setupCronJobs } from "../cron"; -import { setupQueueProcessors } from "../queue"; -import { CronJob, QueueConfig } from "../types"; - -const initializeCronJobs = async (cronConfigs?: CronJob[]) => { - if (!cronConfigs) return; - - setupCronJobs(cronConfigs); -}; - -const initializeQueueProcessors = async (queueConfigs?: QueueConfig[]) => { - if (!queueConfigs) return; - - setupQueueProcessors(queueConfigs); -}; - -export { initializeCronJobs, initializeQueueProcessors }; diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index 8e20d7333..b6cd2b059 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -1,10 +1,7 @@ import { FastifyInstance } from "fastify"; import FastifyPlugin from "fastify-plugin"; -import { - initializeCronJobs, - initializeQueueProcessors, -} from "./lib/initialize"; +import Worker from "./worker"; const plugin = async (fastify: FastifyInstance) => { const { config, log } = fastify; @@ -17,8 +14,16 @@ const plugin = async (fastify: FastifyInstance) => { log.info("Registering worker plugin"); - initializeCronJobs(config.worker.cronJobs); - initializeQueueProcessors(config.worker.queues); + const worker = new Worker(config.worker); + + await worker.start(); + + fastify.decorate("worker", worker); + + fastify.addHook("onClose", async () => { + log.info("Shutting down worker"); + await worker.shutdown(); + }); }; export default FastifyPlugin(plugin); diff --git a/packages/worker/src/queue/adapterRegistry.ts b/packages/worker/src/queue/adapterRegistry.ts new file mode 100644 index 000000000..5ab83412e --- /dev/null +++ b/packages/worker/src/queue/adapterRegistry.ts @@ -0,0 +1,35 @@ +import QueueAdapter from "./adapters/base"; + +class AdapterRegistry { + private adapters = new Map(); + + add(adapter: QueueAdapter): void { + this.adapters.set(adapter.queueName, adapter); + } + + get(name: string): QueueAdapter | undefined { + return this.adapters.get(name); + } + + getAll(): QueueAdapter[] { + return [...this.adapters.values()]; + } + + has(name: string): boolean { + return this.adapters.has(name); + } + + remove(name: string): void { + this.adapters.delete(name); + } + + async shutdownAll(): Promise { + for (const adapter of this.adapters.values()) { + await adapter.shutdown(); + } + + this.adapters.clear(); + } +} + +export default AdapterRegistry; diff --git a/packages/worker/src/queue/adapters/base.ts b/packages/worker/src/queue/adapters/base.ts new file mode 100644 index 000000000..845e7d9cd --- /dev/null +++ b/packages/worker/src/queue/adapters/base.ts @@ -0,0 +1,17 @@ +abstract class QueueAdapter { + public queueName: string; + + constructor(name: string) { + this.queueName = name; + } + + abstract start(): Promise; + abstract shutdown(): Promise; + abstract getClient(): unknown; + abstract push( + data: Payload, + options?: Record, + ): Promise; +} + +export default QueueAdapter; diff --git a/packages/worker/src/queue/adapters/bullmq.ts b/packages/worker/src/queue/adapters/bullmq.ts new file mode 100644 index 000000000..6dd3a3192 --- /dev/null +++ b/packages/worker/src/queue/adapters/bullmq.ts @@ -0,0 +1,83 @@ +import { + Queue as BullQueue, + Worker, + Job, + QueueOptions, + WorkerOptions, + JobsOptions, +} from "bullmq"; + +import QueueAdapter from "./base"; + +export interface BullMQAdapterConfig { + queueOptions: QueueOptions; + workerOptions?: WorkerOptions; + handler: (job: Job) => Promise; + onError?: (error: Error) => void; + onFailed?: (job: Job, error: Error) => void; +} + +class BullMQAdapter extends QueueAdapter { + public queue?: BullQueue; + public worker?: Worker; + private config: BullMQAdapterConfig; + private queueOptions: QueueOptions; + private workerOptions: WorkerOptions; + + constructor(name: string, config: BullMQAdapterConfig) { + super(name); + + this.config = config; + this.queueOptions = config.queueOptions; + this.workerOptions = { + connection: config.queueOptions.connection, + ...config.workerOptions, + }; + } + + async start(): Promise { + this.queue = new BullQueue(this.queueName, this.queueOptions); + this.worker = new Worker( + this.queueName, + async (job: Job) => { + await this.config.handler(job); + }, + this.workerOptions, + ); + + this.worker.on("error", (error) => { + if (this.config.onError) { + this.config.onError(error); + } + }); + + this.worker.on("failed", (job, error) => { + if (this.config.onFailed) { + this.config.onFailed(job as Job, error); + } + }); + } + + async shutdown(): Promise { + await this.worker?.close(); + await this.queue?.close(); + } + + getClient(): BullQueue { + return this.queue!; + } + + async push(data: Payload, options?: JobsOptions): Promise { + try { + const job = await this.queue!.add(this.queueName, data, options); + + return job.id!; + } catch (error) { + throw new Error( + `Failed to push job to BullMQ queue: ${this.queueName}. Error: ${(error as Error).message}`, + ); + } + } +} + +export default BullMQAdapter; diff --git a/packages/worker/src/queue/adapters/index.ts b/packages/worker/src/queue/adapters/index.ts new file mode 100644 index 000000000..266871156 --- /dev/null +++ b/packages/worker/src/queue/adapters/index.ts @@ -0,0 +1,6 @@ +export { default as QueueAdapter } from "./base"; +export { default as BullMQAdapter } from "./bullmq"; +export { default as SQSAdapter } from "./sqs"; + +export type { BullMQAdapterConfig } from "./bullmq"; +export type { SQSAdapterConfig } from "./sqs"; diff --git a/packages/worker/src/queue/adapters/sqs.ts b/packages/worker/src/queue/adapters/sqs.ts new file mode 100644 index 000000000..4a58a5e06 --- /dev/null +++ b/packages/worker/src/queue/adapters/sqs.ts @@ -0,0 +1,124 @@ +import { + DeleteMessageCommand, + Message, + ReceiveMessageCommand, + ReceiveMessageCommandInput, + SendMessageCommand, + SQSClient, + SQSClientConfig, +} from "@aws-sdk/client-sqs"; + +import QueueAdapter from "./base"; + +export interface SQSAdapterConfig { + clientConfig: SQSClientConfig; + handler: (data: unknown) => Promise; + onError?: (error: Error, message?: Message) => void; + queueUrl: string; + receiveMessageOptions?: ReceiveMessageCommandInput; +} + +class SQSAdapter extends QueueAdapter { + private config: SQSAdapterConfig; + public client?: SQSClient; + private queueUrl: string; + private isPolling: boolean = false; + + constructor(name: string, config: SQSAdapterConfig) { + super(name); + + this.config = config; + this.queueUrl = config.queueUrl; + } + + async start(): Promise { + this.client = new SQSClient(this.config.clientConfig); + this.startPolling(); + } + + async shutdown(): Promise { + this.isPolling = false; + this.client?.destroy(); + } + + getClient(): SQSClient { + return this.client!; + } + + private startPolling(): void { + if (this.isPolling) { + return; + } + + this.isPolling = true; + this.poll(); + } + + private async poll(): Promise { + while (this.isPolling) { + try { + const command = new ReceiveMessageCommand({ + QueueUrl: this.queueUrl, + ...this.config.receiveMessageOptions, + }); + + const response = await this.client!.send(command); + + if (response.Messages && response.Messages.length > 0) { + await Promise.all( + response.Messages.map(async (message: Message) => { + try { + const data = JSON.parse(message.Body ?? "{}") as Payload; + + await this.config.handler(data); + + await this.client!.send( + new DeleteMessageCommand({ + QueueUrl: this.queueUrl, + ReceiptHandle: message.ReceiptHandle, + }), + ); + } catch (error) { + if (this.config.onError) { + this.config.onError( + error instanceof Error ? error : new Error(String(error)), + message, + ); + } + } + }), + ); + } + } catch (error) { + if (this.config.onError) { + this.config.onError( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + } + } + + async push( + data: Payload, + options?: Record, + ): Promise { + try { + const command = new SendMessageCommand({ + QueueUrl: this.queueUrl, + MessageBody: JSON.stringify(data), + ...options, + }); + + const response = await this.client!.send(command); + + return response.MessageId!; + } catch (error) { + throw new Error( + `Failed to push job to SQS queue: ${this.queueName}. Error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + +export default SQSAdapter; diff --git a/packages/worker/src/queue/clients/base.ts b/packages/worker/src/queue/clients/base.ts deleted file mode 100644 index aa725d966..000000000 --- a/packages/worker/src/queue/clients/base.ts +++ /dev/null @@ -1,21 +0,0 @@ -abstract class BaseQueueClient { - public queueName: string; - - constructor(name: string) { - this.queueName = name; - } - - abstract getClient(): Payload; - - abstract process( - handler: (data: Payload) => Promise, - concurrency?: number, - ): void; - - abstract push( - data: Payload, - options?: Record, - ): Promise; -} - -export default BaseQueueClient; diff --git a/packages/worker/src/queue/clients/bull.ts b/packages/worker/src/queue/clients/bull.ts deleted file mode 100644 index a62c4290d..000000000 --- a/packages/worker/src/queue/clients/bull.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - Queue as BullQueue, - Worker, - Job, - QueueOptions, - WorkerOptions, - JobsOptions, -} from "bullmq"; - -import BaseQueueClient from "./base"; - -export interface BullMQClientConfig { - queueOptions: QueueOptions; - workerOptions?: WorkerOptions; - handler: (job: Job) => Promise; - onError?: (error: Error) => void; - onFailed?: (job: Job, error: Error) => void; -} - -class BullMqClient extends BaseQueueClient { - public queue: BullQueue; - public worker?: Worker; - private handler: (job: Job) => Promise; - private onError?: (error: Error) => void; - private onFailed?: (job: Job, error: Error) => void; - private queueOptions: QueueOptions; - private workerOptions?: WorkerOptions; - - constructor(name: string, config: BullMQClientConfig) { - super(name); - - this.queueOptions = config.queueOptions; - this.workerOptions = { - connection: config.queueOptions.connection, - ...config.workerOptions, - }; - this.handler = config.handler; - this.onError = config.onError; - this.onFailed = config.onFailed; - this.queue = new BullQueue(this.queueName, this.queueOptions); - this.process(); - } - - getClient(): BullQueue { - return this.queue; - } - - async push(data: Payload, options?: JobsOptions): Promise { - try { - const job = await this.queue.add(this.queueName, data, options); - - return job.id!; - } catch (error) { - throw new Error( - `Failed to push job to BullMQ queue: ${this.queueName}. Error: ${(error as Error).message}`, - ); - } - } - - process(): void { - try { - this.worker = new Worker( - this.queueName, - async (job: Job) => { - await this.handler(job); - }, - this.workerOptions, - ); - - this.worker.on("error", (error) => { - if (this.onError) { - this.onError(error); - } - }); - - this.worker.on("failed", (job, error) => { - if (this.onFailed) { - this.onFailed(job as Job, error); - } - }); - } catch (error) { - throw new Error( - `Failed to process jobs from BullMQ queue: ${this.queueName}. Error: ${(error as Error).message}`, - ); - } - } -} - -export default BullMqClient; diff --git a/packages/worker/src/queue/clients/index.ts b/packages/worker/src/queue/clients/index.ts deleted file mode 100644 index 7ccf0cd53..000000000 --- a/packages/worker/src/queue/clients/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as BaseQueueClient } from "./base"; -export { default as BullMQQueueClient } from "./bull"; -export { default as SQSQueueClient } from "./sqs"; diff --git a/packages/worker/src/queue/clients/sqs.ts b/packages/worker/src/queue/clients/sqs.ts deleted file mode 100644 index 6268f1cf3..000000000 --- a/packages/worker/src/queue/clients/sqs.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - DeleteMessageCommand, - Message, - ReceiveMessageCommand, - ReceiveMessageCommandInput, - SendMessageCommand, - SQSClient, - SQSClientConfig, -} from "@aws-sdk/client-sqs"; - -import BaseQueueClient from "./base"; - -export interface SQSQueueClientConfig { - clientConfig: SQSClientConfig; - handler: (data: unknown) => Promise; - onError?: (error: Error, message?: Message) => void; - queueUrl: string; - receiveMessageOptions?: ReceiveMessageCommandInput; -} - -class SQSQueueClient extends BaseQueueClient { - private config: SQSQueueClientConfig; - public client: SQSClient; - private queueUrl: string; - private isPooling: boolean = false; - - constructor(name: string, config: SQSQueueClientConfig) { - super(name); - - this.config = config; - this.client = new SQSClient(config.clientConfig); - this.queueUrl = config.queueUrl; - - this.process(config.handler); - } - - getClient(): SQSClient { - return this.client; - } - - async process(handler: (data: Payload) => Promise): Promise { - if (this.isPooling) { - return; - } - - this.isPooling = true; - - const pool = async () => { - while (this.isPooling) { - try { - const command = new ReceiveMessageCommand({ - QueueUrl: this.queueUrl, - ...this.config.receiveMessageOptions, - }); - - const response = await this.client.send(command); - - if (response.Messages && response.Messages.length > 0) { - await Promise.all( - response.Messages.map(async (message: Message) => { - try { - const data = JSON.parse(message.Body ?? "{}") as Payload; - - await handler(data); - - await this.client.send( - new DeleteMessageCommand({ - QueueUrl: this.queueUrl, - ReceiptHandle: message.ReceiptHandle, - }), - ); - } catch (error) { - if (this.config.onError) { - this.config.onError( - error instanceof Error ? error : new Error(String(error)), - message, - ); - } - } - }), - ); - } - } catch (error) { - if (this.config.onError) { - this.config.onError( - error instanceof Error ? error : new Error(String(error)), - ); - } - } - } - }; - - pool(); - } - - async push( - data: Payload, - options?: Record, - ): Promise { - try { - const command = new SendMessageCommand({ - QueueUrl: this.queueUrl, - MessageBody: JSON.stringify(data), - ...options, - }); - - const response = await this.client.send(command); - - return response.MessageId!; - } catch (error) { - throw new Error( - `Failed to push job to SQS queue: ${this.queueName}. Error: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } -} - -export default SQSQueueClient; diff --git a/packages/worker/src/queue/factory.ts b/packages/worker/src/queue/factory.ts new file mode 100644 index 000000000..517d017c0 --- /dev/null +++ b/packages/worker/src/queue/factory.ts @@ -0,0 +1,33 @@ +import { QueueProvider } from "../enum"; +import { QueueConfig } from "../types"; +import { QueueAdapter, BullMQAdapter, SQSAdapter } from "./adapters"; + +const createQueueAdapter = (config: QueueConfig): QueueAdapter => { + switch (config.provider) { + case QueueProvider.BULLMQ: { + if (!config.bullmqConfig) { + throw new Error( + `BullMQ configuration is required for queue: ${config.name}`, + ); + } + + return new BullMQAdapter(config.name, config.bullmqConfig); + } + + case QueueProvider.SQS: { + if (!config.sqsConfig) { + throw new Error( + `SQS configuration is required for queue: ${config.name}`, + ); + } + + return new SQSAdapter(config.name, config.sqsConfig); + } + + default: { + throw new Error(`Unsupported queue provider: ${config.provider}`); + } + } +}; + +export default createQueueAdapter; diff --git a/packages/worker/src/queue/index.ts b/packages/worker/src/queue/index.ts index 4b887b5e2..241050086 100644 --- a/packages/worker/src/queue/index.ts +++ b/packages/worker/src/queue/index.ts @@ -1,5 +1,4 @@ -export * from "./clients"; +export * from "./adapters"; -export { default as setupQueueProcessors } from "./setup"; -export { default as QueueProcessor } from "./processor"; -export { default as QueueProcessorRegistry } from "./registry"; +export { default as AdapterRegistry } from "./adapterRegistry"; +export { default as createQueueAdapter } from "./factory"; diff --git a/packages/worker/src/queue/processor.ts b/packages/worker/src/queue/processor.ts deleted file mode 100644 index 0da9efb16..000000000 --- a/packages/worker/src/queue/processor.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { QueueProvider } from "../enum"; -import { BaseQueueClient, BullMQQueueClient, SQSQueueClient } from "./clients"; -import { QueueConfig } from "../types/queue"; - -class QueueProcessor { - private queueClient: BaseQueueClient; - - constructor(config: QueueConfig) { - this.queueClient = this.initializeQueueClient(config); - } - - protected initializeQueueClient(config: QueueConfig) { - let queueClient: BaseQueueClient; - - switch (config.provider) { - case QueueProvider.BULLMQ: { - if (!config.bullmqConfig) { - throw new Error( - `BullMQ configuration is required for queue: ${config.name}`, - ); - } - - queueClient = new BullMQQueueClient(config.name, config.bullmqConfig); - - break; - } - - case QueueProvider.SQS: { - if (!config.sqsConfig) { - throw new Error( - `SQS configuration is required for queue: ${config.name}`, - ); - } - - queueClient = new SQSQueueClient(config.name, config.sqsConfig); - - break; - } - default: { - throw new Error(`Unsupported queue provider: ${config.provider}`); - } - } - - return queueClient; - } - - public getQueueClient() { - return this.queueClient; - } - - public getName() { - return this.queueClient.queueName; - } -} - -export default QueueProcessor; diff --git a/packages/worker/src/queue/registry.ts b/packages/worker/src/queue/registry.ts deleted file mode 100644 index acc150bf2..000000000 --- a/packages/worker/src/queue/registry.ts +++ /dev/null @@ -1,30 +0,0 @@ -import QueueProcessor from "./processor"; - -class QueueProcessorRegistry { - public static queueProcessors: Map = new Map(); - - public static add(queueProcessor: QueueProcessor) { - QueueProcessorRegistry.queueProcessors.set( - queueProcessor.getName(), - queueProcessor, - ); - } - - public static get(queueName: string): QueueProcessor | undefined { - return QueueProcessorRegistry.queueProcessors.get(queueName); - } - - public static getAll(): QueueProcessor[] { - return [...QueueProcessorRegistry.queueProcessors.values()]; - } - - public static has(queueName: string): boolean { - return QueueProcessorRegistry.queueProcessors.has(queueName); - } - - public static remove(queueName: string): void { - QueueProcessorRegistry.queueProcessors.delete(queueName); - } -} - -export default QueueProcessorRegistry; diff --git a/packages/worker/src/queue/setup.ts b/packages/worker/src/queue/setup.ts deleted file mode 100644 index ae7cdabb0..000000000 --- a/packages/worker/src/queue/setup.ts +++ /dev/null @@ -1,17 +0,0 @@ -import QueueProcessor from "./processor"; -import QueueProcessorRegistry from "./registry"; -import { QueueConfig } from "../types"; - -const setupQueueProcessors = (queueConfigs: QueueConfig[]) => { - if (queueConfigs.length === 0) { - return; - } - - for (const queueConfig of queueConfigs) { - const queueProcessor = new QueueProcessor(queueConfig); - - QueueProcessorRegistry.add(queueProcessor); - } -}; - -export default setupQueueProcessors; diff --git a/packages/worker/src/types/queue.ts b/packages/worker/src/types/queue.ts index 623bf27e8..fc3e1e0dd 100644 --- a/packages/worker/src/types/queue.ts +++ b/packages/worker/src/types/queue.ts @@ -1,10 +1,10 @@ import { QueueProvider } from "../enum"; -import { BullMQClientConfig } from "../queue/clients/bull"; -import { SQSQueueClientConfig } from "../queue/clients/sqs"; +import { BullMQAdapterConfig } from "../queue/adapters/bullmq"; +import { SQSAdapterConfig } from "../queue/adapters/sqs"; export interface QueueConfig { - bullmqConfig?: BullMQClientConfig; + bullmqConfig?: BullMQAdapterConfig; name: string; provider: QueueProvider; - sqsConfig?: SQSQueueClientConfig; + sqsConfig?: SQSAdapterConfig; } diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts new file mode 100644 index 000000000..207a57edf --- /dev/null +++ b/packages/worker/src/worker.ts @@ -0,0 +1,38 @@ +import { CronScheduler } from "./cron"; +import { AdapterRegistry, createQueueAdapter } from "./queue"; +import { WorkerConfig } from "./types"; + +class Worker { + public static readonly adapters = new AdapterRegistry(); + public readonly cron: CronScheduler; + private config: WorkerConfig; + + constructor(config: WorkerConfig) { + this.config = config; + this.cron = new CronScheduler(); + } + + async start(): Promise { + if (this.config.cronJobs) { + for (const job of this.config.cronJobs) { + this.cron.schedule(job); + } + } + + if (this.config.queues) { + for (const queueConfig of this.config.queues) { + const adapter = createQueueAdapter(queueConfig); + + await adapter.start(); + Worker.adapters.add(adapter); + } + } + } + + async shutdown(): Promise { + this.cron.stopAll(); + await Worker.adapters.shutdownAll(); + } +} + +export default Worker; From 4c331cab998f035dd48311ce8e95be9f307a1ee4 Mon Sep 17 00:00:00 2001 From: sudeep Date: Mon, 23 Feb 2026 15:04:58 +0545 Subject: [PATCH 19/22] chore: rename workers to JobOrchestrator --- packages/worker/README.md | 16 ++++++++-------- packages/worker/src/index.ts | 2 +- .../worker/src/{worker.ts => jobOrchestrator.ts} | 8 ++++---- packages/worker/src/plugin.ts | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) rename packages/worker/src/{worker.ts => jobOrchestrator.ts} (83%) diff --git a/packages/worker/README.md b/packages/worker/README.md index 618384998..6e5e6ffb6 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -49,33 +49,33 @@ await fastify.register(workerPlugin); ``` ```typescript -import { Worker } from "@prefabs.tech/fastify-worker"; +import { JobOrchestrator } from "@prefabs.tech/fastify-worker"; -const queue = Worker.adapters.get("queue-name") +const queue = JobOrchestrator.adapters.get("queue-name") if (queue) { queue.push({ message: 'Hello world!' }) } ``` -The plugin creates the `Worker` instance, which populates `Worker.adapters` on `start()`. Services import `Worker` and access the static registry directly. On fastify close, `worker.shutdown()` drains all adapters. +The plugin creates the `JobOrchestrator` instance, which populates `JobOrchestrator.adapters` on `start()`. Services import `JobOrchestrator` and access the static registry directly. On fastify close, `jobOrchestrator.shutdown()` drains all adapters. ### Standalone -Use the `Worker` class directly without Fastify: +Use the `JobOrchestrator` class directly without Fastify: ```typescript -import { Worker } from "@prefabs.tech/fastify-worker"; +import { JobOrchestrator } from "@prefabs.tech/fastify-worker"; -const worker = new Worker({ +const jobOrchestrator = new JobOrchestrator({ cronJobs: [...], queues: [...], }); -await worker.start(); +await jobOrchestrator.start(); // later... -await worker.shutdown(); +await jobOrchestrator.shutdown(); ``` ## Configuration diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index eda4a09f4..264c4af2f 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -12,7 +12,7 @@ export { SQSClient } from "@aws-sdk/client-sqs"; export { Job, Queue } from "bullmq"; export { default } from "./plugin"; -export { default as Worker } from "./worker"; +export { default as JobOrchestrator } from "./jobOrchestrator"; export * from "./enum"; export * from "./queue"; diff --git a/packages/worker/src/worker.ts b/packages/worker/src/jobOrchestrator.ts similarity index 83% rename from packages/worker/src/worker.ts rename to packages/worker/src/jobOrchestrator.ts index 207a57edf..cfd35aef9 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/jobOrchestrator.ts @@ -2,7 +2,7 @@ import { CronScheduler } from "./cron"; import { AdapterRegistry, createQueueAdapter } from "./queue"; import { WorkerConfig } from "./types"; -class Worker { +class JobOrchestrator { public static readonly adapters = new AdapterRegistry(); public readonly cron: CronScheduler; private config: WorkerConfig; @@ -24,15 +24,15 @@ class Worker { const adapter = createQueueAdapter(queueConfig); await adapter.start(); - Worker.adapters.add(adapter); + JobOrchestrator.adapters.add(adapter); } } } async shutdown(): Promise { this.cron.stopAll(); - await Worker.adapters.shutdownAll(); + await JobOrchestrator.adapters.shutdownAll(); } } -export default Worker; +export default JobOrchestrator; diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts index b6cd2b059..24e7c0307 100644 --- a/packages/worker/src/plugin.ts +++ b/packages/worker/src/plugin.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from "fastify"; import FastifyPlugin from "fastify-plugin"; -import Worker from "./worker"; +import JobOrchestrator from "./jobOrchestrator"; const plugin = async (fastify: FastifyInstance) => { const { config, log } = fastify; @@ -14,15 +14,15 @@ const plugin = async (fastify: FastifyInstance) => { log.info("Registering worker plugin"); - const worker = new Worker(config.worker); + const jobOrchestrator = new JobOrchestrator(config.worker); - await worker.start(); + await jobOrchestrator.start(); - fastify.decorate("worker", worker); + fastify.decorate("worker", jobOrchestrator); fastify.addHook("onClose", async () => { log.info("Shutting down worker"); - await worker.shutdown(); + await jobOrchestrator.shutdown(); }); }; From e94332a868d10b839835cf80bed2297437a1306b Mon Sep 17 00:00:00 2001 From: sudeep Date: Mon, 23 Feb 2026 18:33:54 +0545 Subject: [PATCH 20/22] test: add unit tests for worker package --- packages/worker/.gitignore | 2 + packages/worker/package.json | 2 + .../src/__test__/cron/scheduler.test.ts | 92 ++++++ .../src/__test__/jobOrchestrator.test.ts | 208 +++++++++++++ packages/worker/src/__test__/plugin.test.ts | 84 ++++++ .../__test__/queue/adapterRegistry.test.ts | 111 +++++++ .../__test__/queue/adapters/bullmq.test.ts | 244 +++++++++++++++ .../src/__test__/queue/adapters/sqs.test.ts | 280 ++++++++++++++++++ .../worker/src/__test__/queue/factory.test.ts | 101 +++++++ pnpm-lock.yaml | 3 + 10 files changed, 1127 insertions(+) create mode 100644 packages/worker/src/__test__/cron/scheduler.test.ts create mode 100644 packages/worker/src/__test__/jobOrchestrator.test.ts create mode 100644 packages/worker/src/__test__/plugin.test.ts create mode 100644 packages/worker/src/__test__/queue/adapterRegistry.test.ts create mode 100644 packages/worker/src/__test__/queue/adapters/bullmq.test.ts create mode 100644 packages/worker/src/__test__/queue/adapters/sqs.test.ts create mode 100644 packages/worker/src/__test__/queue/factory.test.ts diff --git a/packages/worker/.gitignore b/packages/worker/.gitignore index b94707787..1d15bf08a 100644 --- a/packages/worker/.gitignore +++ b/packages/worker/.gitignore @@ -1,2 +1,4 @@ +**/*.log* +/coverage node_modules/ dist/ diff --git a/packages/worker/package.json b/packages/worker/package.json index 44f3dba7b..9dd95bd71 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -27,6 +27,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "sort-package": "npx sort-package-json", + "test": "vitest run --coverage", "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { @@ -39,6 +40,7 @@ "@prefabs.tech/eslint-config": "0.5.0", "@prefabs.tech/fastify-config": "0.93.5", "@prefabs.tech/tsconfig": "0.5.0", + "@vitest/coverage-istanbul": "3.2.4", "eslint": "9.39.2", "fastify": "5.7.4", "fastify-plugin": "5.1.0", diff --git a/packages/worker/src/__test__/cron/scheduler.test.ts b/packages/worker/src/__test__/cron/scheduler.test.ts new file mode 100644 index 000000000..53617eda0 --- /dev/null +++ b/packages/worker/src/__test__/cron/scheduler.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import CronScheduler from "../../cron/scheduler"; + +const { mockStop, mockSchedule } = vi.hoisted(() => { + const mockStop = vi.fn(); + const mockSchedule = vi.fn().mockReturnValue({ stop: mockStop }); + + return { mockStop, mockSchedule }; +}); + +vi.mock("node-cron", () => ({ + default: { + schedule: mockSchedule, + }, +})); + +describe("CronScheduler", () => { + let scheduler: CronScheduler; + + beforeEach(() => { + vi.clearAllMocks(); + mockSchedule.mockReturnValue({ stop: mockStop }); + scheduler = new CronScheduler(); + }); + + describe("schedule", () => { + it("should schedule a cron job with the given expression and task", () => { + const task = vi.fn(); + const job = { expression: "* * * * *", task }; + + scheduler.schedule(job); + + expect(mockSchedule).toHaveBeenCalledWith("* * * * *", task, undefined); + }); + + it("should pass options to node-cron when provided", () => { + const task = vi.fn(); + const options = { scheduled: true, timezone: "UTC" }; + const job = { expression: "0 * * * *", task, options }; + + scheduler.schedule(job); + + expect(mockSchedule).toHaveBeenCalledWith("0 * * * *", task, options); + }); + + it("should track multiple scheduled tasks", () => { + scheduler.schedule({ expression: "* * * * *", task: vi.fn() }); + scheduler.schedule({ expression: "0 * * * *", task: vi.fn() }); + scheduler.schedule({ expression: "0 0 * * *", task: vi.fn() }); + + expect(mockSchedule).toHaveBeenCalledTimes(3); + }); + }); + + describe("stopAll", () => { + it("should stop all scheduled tasks", () => { + const mockStop1 = vi.fn(); + const mockStop2 = vi.fn(); + + mockSchedule + .mockReturnValueOnce({ stop: mockStop1 }) + .mockReturnValueOnce({ stop: mockStop2 }); + + scheduler.schedule({ expression: "* * * * *", task: vi.fn() }); + scheduler.schedule({ expression: "0 * * * *", task: vi.fn() }); + + scheduler.stopAll(); + + expect(mockStop1).toHaveBeenCalledOnce(); + expect(mockStop2).toHaveBeenCalledOnce(); + }); + + it("should clear the tasks list after stopping", () => { + mockSchedule.mockReturnValue({ stop: vi.fn() }); + + scheduler.schedule({ expression: "* * * * *", task: vi.fn() }); + scheduler.stopAll(); + + // Calling stopAll again should not call any stop methods + const newMockStop = vi.fn(); + mockSchedule.mockReturnValue({ stop: newMockStop }); + scheduler.stopAll(); + + expect(newMockStop).not.toHaveBeenCalled(); + }); + + it("should do nothing when no tasks are scheduled", () => { + expect(() => scheduler.stopAll()).not.toThrow(); + }); + }); +}); diff --git a/packages/worker/src/__test__/jobOrchestrator.test.ts b/packages/worker/src/__test__/jobOrchestrator.test.ts new file mode 100644 index 000000000..89d6d9c08 --- /dev/null +++ b/packages/worker/src/__test__/jobOrchestrator.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { QueueProvider } from "../enum"; +import JobOrchestrator from "../jobOrchestrator"; + +const { mockSchedule, mockStopAll, mockAdapterStart, mockAdapterShutdown } = + vi.hoisted(() => ({ + mockSchedule: vi.fn(), + mockStopAll: vi.fn(), + // eslint-disable-next-line unicorn/no-useless-undefined + mockAdapterStart: vi.fn().mockResolvedValue(undefined), + // eslint-disable-next-line unicorn/no-useless-undefined + mockAdapterShutdown: vi.fn().mockResolvedValue(undefined), + })); + +vi.mock("../cron", () => ({ + CronScheduler: vi.fn().mockImplementation(() => ({ + schedule: mockSchedule, + stopAll: mockStopAll, + })), +})); + +vi.mock("../queue", async (importOriginal) => { + const original = await importOriginal(); + + return { + ...original, + createQueueAdapter: vi + .fn() + .mockImplementation((config: { name: string }) => ({ + queueName: config.name, + start: mockAdapterStart, + shutdown: mockAdapterShutdown, + getClient: vi.fn(), + push: vi.fn(), + })), + }; +}); + +describe("JobOrchestrator", () => { + let orchestrator: JobOrchestrator; + + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line unicorn/no-useless-undefined + mockAdapterStart.mockResolvedValue(undefined); + // eslint-disable-next-line unicorn/no-useless-undefined + mockAdapterShutdown.mockResolvedValue(undefined); + }); + + afterEach(async () => { + // Clear static registry between tests to prevent state leakage + await JobOrchestrator.adapters.shutdownAll(); + }); + + describe("constructor", () => { + it("should create a CronScheduler instance", async () => { + const { CronScheduler } = vi.mocked(await import("../cron")); + + orchestrator = new JobOrchestrator({ cronJobs: [], queues: [] }); + + expect(CronScheduler).toHaveBeenCalledOnce(); + expect(orchestrator.cron).toBeDefined(); + }); + }); + + describe("start", () => { + it("should schedule all cron jobs on start", async () => { + const task = vi.fn(); + orchestrator = new JobOrchestrator({ + cronJobs: [ + { expression: "* * * * *", task }, + { expression: "0 * * * *", task }, + ], + }); + + await orchestrator.start(); + + expect(mockSchedule).toHaveBeenCalledTimes(2); + expect(mockSchedule).toHaveBeenCalledWith({ + expression: "* * * * *", + task, + }); + expect(mockSchedule).toHaveBeenCalledWith({ + expression: "0 * * * *", + task, + }); + }); + + it("should create and start all queue adapters on start", async () => { + const { createQueueAdapter } = vi.mocked(await import("../queue")); + + orchestrator = new JobOrchestrator({ + queues: [ + { + bullmqConfig: { + handler: vi.fn(), + queueOptions: { connection: {} }, + }, + name: "queue-1", + provider: QueueProvider.BULLMQ, + }, + { + bullmqConfig: { + handler: vi.fn(), + queueOptions: { connection: {} }, + }, + name: "queue-2", + provider: QueueProvider.BULLMQ, + }, + ], + }); + + await orchestrator.start(); + + expect(createQueueAdapter).toHaveBeenCalledTimes(2); + expect(mockAdapterStart).toHaveBeenCalledTimes(2); + }); + + it("should register adapters in the static registry", async () => { + orchestrator = new JobOrchestrator({ + queues: [ + { + bullmqConfig: { + handler: vi.fn(), + queueOptions: { connection: {} }, + }, + name: "my-queue", + provider: QueueProvider.BULLMQ, + }, + ], + }); + + await orchestrator.start(); + + expect(JobOrchestrator.adapters.has("my-queue")).toBe(true); + }); + + it("should not schedule any cron jobs when cronJobs is undefined", async () => { + orchestrator = new JobOrchestrator({ queues: [] }); + + await orchestrator.start(); + + expect(mockSchedule).not.toHaveBeenCalled(); + }); + + it("should not create any adapters when queues is undefined", async () => { + const { createQueueAdapter } = vi.mocked(await import("../queue")); + orchestrator = new JobOrchestrator({ cronJobs: [] }); + + await orchestrator.start(); + + expect(createQueueAdapter).not.toHaveBeenCalled(); + expect(mockAdapterStart).not.toHaveBeenCalled(); + }); + }); + + describe("shutdown", () => { + it("should stop all cron jobs on shutdown", async () => { + orchestrator = new JobOrchestrator({ cronJobs: [], queues: [] }); + await orchestrator.start(); + + await orchestrator.shutdown(); + + expect(mockStopAll).toHaveBeenCalledOnce(); + }); + + it("should shut down all registered adapters on shutdown", async () => { + orchestrator = new JobOrchestrator({ + queues: [ + { + bullmqConfig: { + handler: vi.fn(), + queueOptions: { connection: {} }, + }, + name: "shutdown-queue", + provider: QueueProvider.BULLMQ, + }, + ], + }); + + await orchestrator.start(); + await orchestrator.shutdown(); + + expect(mockAdapterShutdown).toHaveBeenCalledOnce(); + }); + + it("should clear the adapter registry after shutdown", async () => { + orchestrator = new JobOrchestrator({ + queues: [ + { + bullmqConfig: { + handler: vi.fn(), + queueOptions: { connection: {} }, + }, + name: "clear-queue", + provider: QueueProvider.BULLMQ, + }, + ], + }); + + await orchestrator.start(); + await orchestrator.shutdown(); + + expect(JobOrchestrator.adapters.getAll()).toHaveLength(0); + }); + }); +}); diff --git a/packages/worker/src/__test__/plugin.test.ts b/packages/worker/src/__test__/plugin.test.ts new file mode 100644 index 000000000..952a94e77 --- /dev/null +++ b/packages/worker/src/__test__/plugin.test.ts @@ -0,0 +1,84 @@ +import fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { FastifyInstance } from "fastify"; + +const { mockStart, mockShutdown, MockJobOrchestrator } = vi.hoisted(() => { + // eslint-disable-next-line unicorn/no-useless-undefined + const mockStart = vi.fn().mockResolvedValue(undefined); + // eslint-disable-next-line unicorn/no-useless-undefined + const mockShutdown = vi.fn().mockResolvedValue(undefined); + const MockJobOrchestrator = vi.fn().mockImplementation(() => ({ + cron: {}, + shutdown: mockShutdown, + start: mockStart, + })); + + return { mockStart, mockShutdown, MockJobOrchestrator }; +}); + +vi.mock("../jobOrchestrator", () => ({ + default: MockJobOrchestrator, +})); + +describe("Worker plugin", async () => { + let api: FastifyInstance; + const { default: plugin } = await import("../plugin"); + + const workerConfig = { + cronJobs: [], + queues: [], + }; + + beforeEach(async () => { + vi.clearAllMocks(); + // eslint-disable-next-line unicorn/no-useless-undefined + mockStart.mockResolvedValue(undefined); + // eslint-disable-next-line unicorn/no-useless-undefined + mockShutdown.mockResolvedValue(undefined); + api = fastify(); + }); + + afterEach(async () => { + // Suppress error if api was already closed inside the test + await api.close().catch(() => {}); + }); + + it("should log a warning and skip registration when worker config is missing", async () => { + api.decorate("config", {} as never); + + await api.register(plugin); + await api.ready(); + + expect(MockJobOrchestrator).not.toHaveBeenCalled(); + }); + + it("should create a JobOrchestrator and call start when worker config is present", async () => { + api.decorate("config", { worker: workerConfig } as never); + + await api.register(plugin); + await api.ready(); + + expect(MockJobOrchestrator).toHaveBeenCalledWith(workerConfig); + expect(mockStart).toHaveBeenCalledOnce(); + }); + + it("should decorate the fastify instance with the worker orchestrator", async () => { + api.decorate("config", { worker: workerConfig } as never); + + await api.register(plugin); + await api.ready(); + + expect((api as FastifyInstance & { worker: unknown }).worker).toBeDefined(); + }); + + it("should call shutdown on the orchestrator when fastify closes", async () => { + api.decorate("config", { worker: workerConfig } as never); + + await api.register(plugin); + await api.ready(); + await api.close(); + + expect(mockShutdown).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/worker/src/__test__/queue/adapterRegistry.test.ts b/packages/worker/src/__test__/queue/adapterRegistry.test.ts new file mode 100644 index 000000000..5782dcf84 --- /dev/null +++ b/packages/worker/src/__test__/queue/adapterRegistry.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AdapterRegistry from "../../queue/adapterRegistry"; +import QueueAdapter from "../../queue/adapters/base"; + +class MockAdapter extends QueueAdapter { + // eslint-disable-next-line unicorn/no-useless-undefined + start = vi.fn().mockResolvedValue(undefined); + // eslint-disable-next-line unicorn/no-useless-undefined + shutdown = vi.fn().mockResolvedValue(undefined); + getClient = vi.fn().mockReturnValue({}); + push = vi.fn().mockResolvedValue("job-id"); +} + +describe("AdapterRegistry", () => { + let registry: AdapterRegistry; + let adapterA: MockAdapter; + let adapterB: MockAdapter; + + beforeEach(() => { + registry = new AdapterRegistry(); + adapterA = new MockAdapter("queue-a"); + adapterB = new MockAdapter("queue-b"); + }); + + describe("add / get", () => { + it("should add an adapter and retrieve it by name", () => { + registry.add(adapterA); + + expect(registry.get("queue-a")).toBe(adapterA); + }); + + it("should return undefined for an unregistered adapter name", () => { + expect(registry.get("non-existent")).toBeUndefined(); + }); + + it("should overwrite an existing adapter with the same name", () => { + const replacement = new MockAdapter("queue-a"); + registry.add(adapterA); + registry.add(replacement); + + expect(registry.get("queue-a")).toBe(replacement); + }); + }); + + describe("getAll", () => { + it("should return all registered adapters", () => { + registry.add(adapterA); + registry.add(adapterB); + + expect(registry.getAll()).toHaveLength(2); + expect(registry.getAll()).toContain(adapterA); + expect(registry.getAll()).toContain(adapterB); + }); + + it("should return an empty array when no adapters are registered", () => { + expect(registry.getAll()).toEqual([]); + }); + }); + + describe("has", () => { + it("should return true when adapter exists", () => { + registry.add(adapterA); + + expect(registry.has("queue-a")).toBe(true); + }); + + it("should return false when adapter does not exist", () => { + expect(registry.has("queue-a")).toBe(false); + }); + }); + + describe("remove", () => { + it("should remove an adapter by name", () => { + registry.add(adapterA); + registry.remove("queue-a"); + + expect(registry.has("queue-a")).toBe(false); + expect(registry.get("queue-a")).toBeUndefined(); + }); + + it("should not throw when removing a non-existent adapter", () => { + expect(() => registry.remove("non-existent")).not.toThrow(); + }); + }); + + describe("shutdownAll", () => { + it("should call shutdown on all adapters", async () => { + registry.add(adapterA); + registry.add(adapterB); + + await registry.shutdownAll(); + + expect(adapterA.shutdown).toHaveBeenCalledOnce(); + expect(adapterB.shutdown).toHaveBeenCalledOnce(); + }); + + it("should clear all adapters after shutdown", async () => { + registry.add(adapterA); + registry.add(adapterB); + + await registry.shutdownAll(); + + expect(registry.getAll()).toEqual([]); + }); + + it("should resolve without error when no adapters are registered", async () => { + await expect(registry.shutdownAll()).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/worker/src/__test__/queue/adapters/bullmq.test.ts b/packages/worker/src/__test__/queue/adapters/bullmq.test.ts new file mode 100644 index 000000000..804cbcdf5 --- /dev/null +++ b/packages/worker/src/__test__/queue/adapters/bullmq.test.ts @@ -0,0 +1,244 @@ +import { Job, JobsOptions } from "bullmq"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import BullMQAdapter from "../../../queue/adapters/bullmq"; + +const { + mockQueueAdd, + mockQueueClose, + mockWorkerClose, + mockWorkerOn, + capturedHandler, + eventListeners, + MockQueue, + MockWorker, +} = vi.hoisted(() => { + const mockQueueAdd = vi.fn().mockResolvedValue({ id: "job-123" }); + // eslint-disable-next-line unicorn/no-useless-undefined + const mockQueueClose = vi.fn().mockResolvedValue(undefined); + // eslint-disable-next-line unicorn/no-useless-undefined + const mockWorkerClose = vi.fn().mockResolvedValue(undefined); + + const eventListeners: Record void> = {}; + const mockWorkerOn = vi + .fn() + .mockImplementation( + (event: string, callback: (...arguments_: unknown[]) => void) => { + eventListeners[event] = callback; + }, + ); + + const capturedHandler = { + fn: undefined as ((job: unknown) => Promise) | undefined, + }; + + const MockQueue = vi.fn().mockImplementation(() => ({ + add: mockQueueAdd, + close: mockQueueClose, + })); + + const MockWorker = vi + .fn() + .mockImplementation( + (_name: string, handler: (job: unknown) => Promise) => { + capturedHandler.fn = handler; + return { on: mockWorkerOn, close: mockWorkerClose }; + }, + ); + + return { + MockQueue, + MockWorker, + capturedHandler, + eventListeners, + mockQueueAdd, + mockQueueClose, + mockWorkerClose, + mockWorkerOn, + }; +}); + +vi.mock("bullmq", () => ({ + Job: class {}, + Queue: MockQueue, + Worker: MockWorker, +})); + +const baseConfig = { + // eslint-disable-next-line unicorn/no-useless-undefined + handler: vi.fn().mockResolvedValue(undefined), + queueOptions: { + connection: { host: "localhost", port: 6379 }, + }, +}; + +describe("BullMQAdapter", () => { + let adapter: BullMQAdapter<{ key: string }>; + + beforeEach(() => { + vi.clearAllMocks(); + mockQueueAdd.mockResolvedValue({ id: "job-123" }); + adapter = new BullMQAdapter("test-queue", baseConfig); + }); + + describe("start", () => { + it("should create a BullMQ Queue with the given name and options", async () => { + await adapter.start(); + + expect(MockQueue).toHaveBeenCalledWith( + "test-queue", + baseConfig.queueOptions, + ); + }); + + it("should create a Worker with the queue name and connection", async () => { + await adapter.start(); + + expect(MockWorker).toHaveBeenCalledWith( + "test-queue", + expect.any(Function), + { connection: baseConfig.queueOptions.connection }, + ); + }); + + it("should merge workerOptions with connection from queueOptions", async () => { + const config = { + ...baseConfig, + workerOptions: { + concurrency: 5, + connection: baseConfig.queueOptions.connection, + }, + }; + const adapterWithWorkerOptions = new BullMQAdapter("test-queue", config); + + await adapterWithWorkerOptions.start(); + + expect(MockWorker).toHaveBeenCalledWith( + "test-queue", + expect.any(Function), + { connection: baseConfig.queueOptions.connection, concurrency: 5 }, + ); + }); + + it("should register error and failed event listeners on the worker", async () => { + await adapter.start(); + + expect(mockWorkerOn).toHaveBeenCalledWith("error", expect.any(Function)); + expect(mockWorkerOn).toHaveBeenCalledWith("failed", expect.any(Function)); + }); + + it("should invoke the job handler when the worker processes a job", async () => { + await adapter.start(); + + const mockJob = { data: { key: "value" } } as Job; + await capturedHandler.fn!(mockJob); + + expect(baseConfig.handler).toHaveBeenCalledWith(mockJob); + }); + }); + + describe("shutdown", () => { + it("should close the worker and queue", async () => { + await adapter.start(); + await adapter.shutdown(); + + expect(mockWorkerClose).toHaveBeenCalledOnce(); + expect(mockQueueClose).toHaveBeenCalledOnce(); + }); + + it("should not throw if called before start", async () => { + await expect(adapter.shutdown()).resolves.not.toThrow(); + }); + }); + + describe("getClient", () => { + it("should return the underlying BullMQ Queue instance", async () => { + await adapter.start(); + + expect(adapter.getClient()).toBeDefined(); + expect(adapter.getClient()).toHaveProperty("add"); + }); + }); + + describe("push", () => { + it("should add a job to the queue and return the job id", async () => { + await adapter.start(); + + const id = await adapter.push({ key: "value" }); + + expect(mockQueueAdd).toHaveBeenCalledWith( + "test-queue", + { key: "value" }, + undefined, + ); + expect(id).toBe("job-123"); + }); + + it("should pass job options to queue.add", async () => { + await adapter.start(); + const options: JobsOptions = { delay: 1000 }; + + await adapter.push({ key: "value" }, options); + + expect(mockQueueAdd).toHaveBeenCalledWith( + "test-queue", + { key: "value" }, + options, + ); + }); + + it("should throw a descriptive error when queue.add fails", async () => { + await adapter.start(); + mockQueueAdd.mockRejectedValueOnce(new Error("Redis connection refused")); + + await expect(adapter.push({ key: "value" })).rejects.toThrowError( + "Failed to push job to BullMQ queue: test-queue. Error: Redis connection refused", + ); + }); + }); + + describe("event handlers", () => { + it("should call onError when the worker emits an error", async () => { + const onError = vi.fn(); + const adapterWithError = new BullMQAdapter("test-queue", { + ...baseConfig, + onError, + }); + await adapterWithError.start(); + + const error = new Error("worker error"); + eventListeners["error"](error); + + expect(onError).toHaveBeenCalledWith(error); + }); + + it("should not throw when an error is emitted with no onError handler", async () => { + await adapter.start(); + + expect(() => eventListeners["error"](new Error("error"))).not.toThrow(); + }); + + it("should call onFailed when the worker emits a failed event", async () => { + const onFailed = vi.fn(); + const adapterWithFailed = new BullMQAdapter("test-queue", { + ...baseConfig, + onFailed, + }); + await adapterWithFailed.start(); + + const job = { id: "job-1" } as Job; + const error = new Error("job failed"); + eventListeners["failed"](job, error); + + expect(onFailed).toHaveBeenCalledWith(job, error); + }); + + it("should not throw when a failed event is emitted with no onFailed handler", async () => { + await adapter.start(); + + expect(() => + eventListeners["failed"]({ id: "job-1" }, new Error("error")), + ).not.toThrow(); + }); + }); +}); diff --git a/packages/worker/src/__test__/queue/adapters/sqs.test.ts b/packages/worker/src/__test__/queue/adapters/sqs.test.ts new file mode 100644 index 000000000..72538095b --- /dev/null +++ b/packages/worker/src/__test__/queue/adapters/sqs.test.ts @@ -0,0 +1,280 @@ +import { + DeleteMessageCommand, + ReceiveMessageCommand, + SendMessageCommand, +} from "@aws-sdk/client-sqs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SQSAdapter from "../../../queue/adapters/sqs"; + +const { mockClientSend, mockClientDestroy, MockSQSClient } = vi.hoisted(() => { + const mockClientSend = vi.fn(); + const mockClientDestroy = vi.fn(); + const MockSQSClient = vi.fn().mockImplementation(() => ({ + destroy: mockClientDestroy, + send: mockClientSend, + })); + + return { mockClientSend, mockClientDestroy, MockSQSClient }; +}); + +vi.mock("@aws-sdk/client-sqs", () => { + class ReceiveMessageCommand { + input: Record; + constructor(input: Record) { + this.input = input; + } + } + class DeleteMessageCommand { + input: Record; + constructor(input: Record) { + this.input = input; + } + } + class SendMessageCommand { + input: Record; + constructor(input: Record) { + this.input = input; + } + } + + return { + DeleteMessageCommand, + ReceiveMessageCommand, + SendMessageCommand, + SQSClient: MockSQSClient, + }; +}); + +const waitFor = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms)); +const neverResolve = () => new Promise(() => {}); + +const baseConfig = { + clientConfig: { region: "us-east-1" }, + // eslint-disable-next-line unicorn/no-useless-undefined + handler: vi.fn().mockResolvedValue(undefined), + queueUrl: "https://sqs.us-east-1.amazonaws.com/123456789/test-queue", +}; + +describe("SQSAdapter", () => { + let adapter: SQSAdapter<{ key: string }>; + + beforeEach(() => { + vi.clearAllMocks(); + mockClientSend.mockImplementation(neverResolve); + adapter = new SQSAdapter("sqs-queue", baseConfig); + }); + + describe("start", () => { + it("should create an SQSClient with the provided config", async () => { + await adapter.start(); + + expect(MockSQSClient).toHaveBeenCalledWith(baseConfig.clientConfig); + }); + + it("should set isPolling to true when start is called", async () => { + await adapter.start(); + + expect(adapter["isPolling"]).toBe(true); + }); + + it("should send a ReceiveMessageCommand once polling starts", async () => { + await adapter.start(); + + // poll() calls send() synchronously before its first await + expect(mockClientSend).toHaveBeenCalledWith( + expect.any(ReceiveMessageCommand), + ); + }); + + it("should include custom receiveMessageOptions in the ReceiveMessageCommand", async () => { + const configWithOptions = { + ...baseConfig, + receiveMessageOptions: { + MaxNumberOfMessages: 5, + QueueUrl: baseConfig.queueUrl, + }, + }; + const customAdapter = new SQSAdapter("sqs-queue", configWithOptions); + + await customAdapter.start(); + + const callArgument = mockClientSend.mock + .calls[0][0] as ReceiveMessageCommand; + expect(callArgument.input).toMatchObject({ + MaxNumberOfMessages: 5, + QueueUrl: baseConfig.queueUrl, + }); + }); + }); + + describe("shutdown", () => { + it("should set isPolling to false and destroy the client", async () => { + await adapter.start(); + await adapter.shutdown(); + + expect(adapter["isPolling"]).toBe(false); + expect(mockClientDestroy).toHaveBeenCalledOnce(); + }); + + it("should not throw if called before start", async () => { + await expect(adapter.shutdown()).resolves.not.toThrow(); + }); + }); + + describe("getClient", () => { + it("should return the underlying SQSClient instance", async () => { + await adapter.start(); + + expect(adapter.getClient()).toBeDefined(); + expect(adapter.getClient()).toHaveProperty("send"); + }); + }); + + describe("push", () => { + it("should send a SendMessageCommand and return the message id", async () => { + await adapter.start(); + // The poll loop is suspended on neverResolve — this once-value goes to push + mockClientSend.mockResolvedValueOnce({ MessageId: "msg-abc-123" }); + + const id = await adapter.push({ key: "value" }); + + const sendCall = mockClientSend.mock.calls.find( + (call) => call[0] instanceof SendMessageCommand, + ); + expect(sendCall).toBeDefined(); + expect((sendCall![0] as SendMessageCommand).input).toMatchObject({ + MessageBody: JSON.stringify({ key: "value" }), + QueueUrl: baseConfig.queueUrl, + }); + expect(id).toBe("msg-abc-123"); + }); + + it("should spread extra options into the SendMessageCommand", async () => { + await adapter.start(); + mockClientSend.mockResolvedValueOnce({ MessageId: "msg-xyz" }); + + await adapter.push( + { key: "value" }, + { MessageGroupId: "group-1", MessageDeduplicationId: "dedup-1" }, + ); + + const sendCall = mockClientSend.mock.calls.find( + (call) => call[0] instanceof SendMessageCommand, + ); + expect((sendCall![0] as SendMessageCommand).input).toMatchObject({ + MessageDeduplicationId: "dedup-1", + MessageGroupId: "group-1", + }); + }); + + it("should throw a descriptive error when send fails", async () => { + await adapter.start(); + mockClientSend.mockRejectedValueOnce(new Error("SQS unavailable")); + + await expect(adapter.push({ key: "value" })).rejects.toThrowError( + "Failed to push job to SQS queue: sqs-queue. Error: SQS unavailable", + ); + }); + }); + + describe("polling", () => { + it("should call the handler and delete the message when a message is received", async () => { + // Create the adapter first so we can reference it inside the mock + const pollingAdapter = new SQSAdapter("sqs-queue", baseConfig); + let sendCallCount = 0; + mockClientSend.mockImplementation(async () => { + sendCallCount++; + if (sendCallCount === 1) { + return { + Messages: [ + { Body: '{"key":"polled"}', ReceiptHandle: "receipt-handle-1" }, + ], + }; + } + // After first receive + delete, stop the loop + pollingAdapter["isPolling"] = false; + return {}; + }); + + await pollingAdapter.start(); + await waitFor(); + + expect(baseConfig.handler).toHaveBeenCalledWith({ key: "polled" }); + + const deleteCall = mockClientSend.mock.calls.find( + (call) => call[0] instanceof DeleteMessageCommand, + ); + expect(deleteCall).toBeDefined(); + expect((deleteCall![0] as DeleteMessageCommand).input).toMatchObject({ + QueueUrl: baseConfig.queueUrl, + ReceiptHandle: "receipt-handle-1", + }); + }); + + it("should call onError when the handler throws during message processing", async () => { + const onError = vi.fn(); + const errorAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + handler: vi.fn().mockRejectedValueOnce(new Error("handler error")), + onError, + }); + let sendCallCount = 0; + + mockClientSend.mockImplementation(async () => { + sendCallCount++; + if (sendCallCount === 1) { + return { + Messages: [ + { Body: '{"key":"value"}', ReceiptHandle: "receipt-handle-1" }, + ], + }; + } + errorAdapter["isPolling"] = false; + return {}; + }); + + await errorAdapter.start(); + await waitFor(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "handler error" }), + expect.objectContaining({ ReceiptHandle: "receipt-handle-1" }), + ); + }); + + it("should call onError when ReceiveMessageCommand itself fails", async () => { + const onError = vi.fn(); + const errorAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + onError, + }); + let sendCallCount = 0; + + mockClientSend.mockImplementation(async () => { + sendCallCount++; + if (sendCallCount === 1) { + throw new Error("SQS network error"); + } + errorAdapter["isPolling"] = false; + return { Messages: [] }; + }); + + await errorAdapter.start(); + await waitFor(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "SQS network error" }), + ); + }); + + it("should not start a second polling loop if already polling", async () => { + await adapter.start(); + // Calling startPolling again while isPolling=true should be a no-op + adapter["startPolling"](); + + // Only the initial ReceiveMessageCommand should have been dispatched + expect(mockClientSend).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/worker/src/__test__/queue/factory.test.ts b/packages/worker/src/__test__/queue/factory.test.ts new file mode 100644 index 000000000..e06056ed5 --- /dev/null +++ b/packages/worker/src/__test__/queue/factory.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; + +import { QueueProvider } from "../../enum"; +import BullMQAdapter from "../../queue/adapters/bullmq"; +import SQSAdapter from "../../queue/adapters/sqs"; +import createQueueAdapter from "../../queue/factory"; + +vi.mock("../../queue/adapters/bullmq", () => ({ + default: vi.fn().mockImplementation((name: string) => ({ + queueName: name, + })), +})); + +vi.mock("../../queue/adapters/sqs", () => ({ + default: vi.fn().mockImplementation((name: string) => ({ + queueName: name, + })), +})); + +const mockBullMQConfig = { + handler: vi.fn(), + queueOptions: { + connection: { host: "localhost", port: 6379 }, + }, +}; + +const mockSQSConfig = { + clientConfig: { region: "us-east-1" }, + handler: vi.fn(), + queueUrl: "https://sqs.us-east-1.amazonaws.com/123/test-queue", +}; + +describe("createQueueAdapter", () => { + describe("BullMQ provider", () => { + it("should create a BullMQAdapter for BULLMQ provider", () => { + const config = { + bullmqConfig: mockBullMQConfig, + name: "test-queue", + provider: QueueProvider.BULLMQ, + }; + + const adapter = createQueueAdapter(config); + + expect(BullMQAdapter).toHaveBeenCalledWith( + "test-queue", + mockBullMQConfig, + ); + expect(adapter).toBeDefined(); + }); + + it("should throw when BullMQ config is missing", () => { + const config = { + name: "test-queue", + provider: QueueProvider.BULLMQ, + }; + + expect(() => createQueueAdapter(config)).toThrowError( + "BullMQ configuration is required for queue: test-queue", + ); + }); + }); + + describe("SQS provider", () => { + it("should create an SQSAdapter for SQS provider", () => { + const config = { + name: "sqs-queue", + provider: QueueProvider.SQS, + sqsConfig: mockSQSConfig, + }; + + const adapter = createQueueAdapter(config); + + expect(SQSAdapter).toHaveBeenCalledWith("sqs-queue", mockSQSConfig); + expect(adapter).toBeDefined(); + }); + + it("should throw when SQS config is missing", () => { + const config = { + name: "sqs-queue", + provider: QueueProvider.SQS, + }; + + expect(() => createQueueAdapter(config)).toThrowError( + "SQS configuration is required for queue: sqs-queue", + ); + }); + }); + + describe("unsupported provider", () => { + it("should throw for an unsupported provider value", () => { + const config = { + name: "unknown-queue", + provider: "kafka" as QueueProvider, + }; + + expect(() => createQueueAdapter(config)).toThrowError( + "Unsupported queue provider: kafka", + ); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a128911c3..eb5d4e434 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,6 +620,9 @@ importers: '@prefabs.tech/tsconfig': specifier: 0.5.0 version: 0.5.0(@types/node@24.10.13) + '@vitest/coverage-istanbul': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) eslint: specifier: 9.39.2 version: 9.39.2(jiti@2.6.1) From 6508d214562b2c33b659d6a8e3698558e8e1ac1d Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Wed, 13 May 2026 16:29:04 +0545 Subject: [PATCH 21/22] chore: refresh package docs/tests and extend worker queue - Add FEATURES/GUIDE and colocated Vitest suites across published packages - Extend @prefabs.tech/worker (adapters, orchestration, docs, integration test) - Update root tooling (workflows, turbo, renovate, lockfile) --- .claude/skills/CONVENTIONS.md | 44 + .commitlintrc.json | 4 +- .github/workflows/shipjs-trigger.yml | 10 +- .github/workflows/test.yml | 3 +- .gitignore | 2 + CHANGELOG.md | 1449 ++--- README.md | 31 +- package.json | 26 +- packages/config/ADR-CONFIG.md | 426 ++ packages/config/FEATURES.md | 64 + packages/config/GUIDE.md | 269 + packages/config/README.md | 95 +- packages/config/package.json | 18 +- packages/config/src/__test__/parse.test.ts | 40 +- packages/config/src/__test__/plugin.test.ts | 289 + packages/config/src/index.ts | 4 +- packages/config/src/plugin.ts | 3 +- packages/config/src/types.ts | 22 +- packages/config/tsconfig.json | 10 +- packages/config/vite.config.ts | 3 +- packages/error-handler/FEATURES.md | 69 + packages/error-handler/GUIDE.md | 307 + packages/error-handler/README.md | 164 +- packages/error-handler/package.json | 20 +- .../src/__test__/domainStatusMap.test.ts | 121 + .../src/__test__/errorHandlerExport.test.ts | 32 + .../src/__test__/errorHandling.test.ts | 130 + .../error-handler/src/__test__/helpers.ts | 42 + .../src/__test__/httpErrors.test.ts | 76 + .../src/__test__/logging.test.ts | 60 + .../src/__test__/masking.test.ts | 105 + .../src/__test__/preErrorHandler.test.ts | 94 + .../src/__test__/registration.test.ts | 67 + .../src/__test__/stackTrace.test.ts | 47 + packages/error-handler/src/errorHandler.ts | 92 +- packages/error-handler/src/index.ts | 7 +- packages/error-handler/src/plugin.ts | 43 +- packages/error-handler/src/types.ts | 7 +- .../error-handler/src/utils/errorSchema.ts | 8 +- packages/error-handler/tsconfig.json | 8 +- packages/error-handler/vite.config.ts | 3 +- packages/firebase/FEATURES.md | 110 + packages/firebase/GUIDE.md | 649 ++ packages/firebase/README.md | 52 +- packages/firebase/__test__/service.spec.ts | 9 - packages/firebase/package.json | 49 +- .../firebase/src/__test__/controllers.test.ts | 134 + .../firebase/src/__test__/handlers.test.ts | 288 + .../src/__test__/helpers/createConfig.ts | 32 + .../src/__test__/helpers/createDatabase.ts | 56 + .../src/__test__/initializeFirebase.test.ts | 130 + .../src/__test__/isFirebaseEnabled.test.ts | 42 + .../src/__test__/libraryAndMigrations.test.ts | 79 + .../src/__test__/notificationResolver.test.ts | 121 + packages/firebase/src/__test__/plugin.test.ts | 296 + .../firebase/src/__test__/queries.test.ts | 41 + .../firebase/src/__test__/service.test.ts | 150 + .../firebase/src/__test__/sqlFactory.test.ts | 70 + .../src/__test__/userDeviceResolver.test.ts | 160 + packages/firebase/src/index.ts | 56 +- .../firebase/src/lib/initializeFirebase.ts | 8 +- .../firebase/src/lib/sendPushNotification.ts | 4 +- packages/firebase/src/migrations/queries.ts | 8 +- .../firebase/src/migrations/runMigrations.ts | 4 +- .../src/model/notification/controller.ts | 16 +- .../model/notification/graphql/resolver.ts | 20 +- .../notification/handlers/sendNotification.ts | 23 +- .../firebase/src/model/notification/schema.ts | 14 +- .../src/model/userDevice/controller.ts | 8 +- .../src/model/userDevice/graphql/resolver.ts | 6 +- .../userDevice/handlers/addUserDevice.ts | 9 +- .../userDevice/handlers/removeUserDevice.ts | 4 +- .../firebase/src/model/userDevice/schema.ts | 22 +- .../firebase/src/model/userDevice/service.ts | 22 +- .../src/model/userDevice/sqlFactory.ts | 8 +- packages/firebase/src/plugin.ts | 6 +- packages/firebase/src/types.ts | 22 +- packages/firebase/tsconfig.json | 10 +- packages/firebase/vite.config.ts | 5 +- packages/graphql/FEATURES.md | 51 + packages/graphql/GUIDE.md | 566 ++ packages/graphql/README.md | 42 +- packages/graphql/package.json | 38 +- .../graphql/src/__test__/baseSchema.test.ts | 19 + packages/graphql/src/__test__/context.spec.ts | 22 +- .../src/__test__/helpers/createConfig.ts | 23 +- .../src/__test__/helpers/testPlugin.ts | 5 +- .../src/__test__/helpers/testPluginAsync.ts | 5 +- packages/graphql/src/__test__/plugin.test.ts | 238 + packages/graphql/src/buildContext.ts | 5 +- packages/graphql/src/index.ts | 11 +- packages/graphql/src/plugin.ts | 7 +- packages/graphql/src/types.ts | 4 +- packages/graphql/tsconfig.json | 8 +- packages/graphql/vite.config.ts | 5 +- packages/mailer/ANALYSIS.md | 116 + packages/mailer/FEATURES.md | 77 + packages/mailer/GUIDE.md | 592 ++ packages/mailer/README.md | 23 +- packages/mailer/package.json | 30 +- .../__test__/helpers/createMailerConfig.ts | 4 +- packages/mailer/src/__test__/mailer.spec.ts | 83 - .../mailer/src/__test__/recipients.test.ts | 151 + .../mailer/src/__test__/registration.test.ts | 169 + packages/mailer/src/__test__/schema.test.ts | 18 + packages/mailer/src/__test__/sendMail.test.ts | 214 + .../mailer/src/__test__/testRoute.test.ts | 162 + packages/mailer/src/index.ts | 3 +- packages/mailer/src/plugin.ts | 19 +- packages/mailer/src/router.ts | 8 +- packages/mailer/src/schema.ts | 16 +- packages/mailer/src/types.ts | 18 +- packages/mailer/vite.config.ts | 3 +- packages/s3/FEATURES.md | 115 + packages/s3/GUIDE.md | 855 +++ packages/s3/README.md | 173 +- packages/s3/package.json | 50 +- .../s3/src/__test__/multipartParser.test.ts | 126 + packages/s3/src/__test__/plugin.test.ts | 242 + packages/s3/src/__test__/s3Client.test.ts | 211 + packages/s3/src/__test__/service.test.ts | 356 ++ packages/s3/src/__test__/sqlFactory.test.ts | 106 + packages/s3/src/__test__/utils.test.ts | 163 + packages/s3/src/constants.ts | 2 +- packages/s3/src/index.ts | 15 +- packages/s3/src/migrations/queries.ts | 6 +- packages/s3/src/migrations/runMigrations.ts | 4 +- packages/s3/src/model/files/service.ts | 130 +- packages/s3/src/plugin.ts | 10 +- .../plugins/__test__/ajvFilePlugin.test.ts | 14 +- .../plugins/__test__/graphqlUpload.test.ts | 60 + packages/s3/src/plugins/ajvFile.ts | 2 +- packages/s3/src/plugins/graphqlUpload.ts | 4 +- packages/s3/src/plugins/multipartParser.ts | 4 +- packages/s3/src/types/file.ts | 16 +- packages/s3/src/types/index.ts | 47 +- packages/s3/src/utils/index.ts | 14 +- packages/s3/src/utils/s3Client.ts | 111 +- packages/s3/vite.config.ts | 11 +- packages/slonik/FEATURES.md | 187 + packages/slonik/GUIDE.md | 930 +++ packages/slonik/README.md | 42 +- packages/slonik/feature.md | 2146 +++++++ packages/slonik/package-lock.json | 5626 ----------------- packages/slonik/package.json | 36 +- packages/slonik/src/__test__/filters.test.ts | 281 +- .../src/__test__/helpers/createConfig.ts | 5 +- .../src/__test__/helpers/createDatabase.ts | 4 +- .../src/__test__/helpers/testService.ts | 6 +- packages/slonik/src/__test__/helpers/utils.ts | 12 +- packages/slonik/src/__test__/migrate.test.ts | 90 + .../src/__test__/migrationPlugin.test.ts | 66 + packages/slonik/src/__test__/plugin.test.ts | 192 + packages/slonik/src/__test__/service.test.ts | 64 +- .../src/__test__/serviceWithHooks.test.ts | 119 +- packages/slonik/src/__test__/sql.test.ts | 154 +- .../slonik/src/__test__/sqlFactory.test.ts | 133 + packages/slonik/src/createDatabase.ts | 7 +- .../createClientConfiguration.test.ts | 33 +- .../factories/createClientConfiguration.ts | 4 +- packages/slonik/src/filters.ts | 78 +- packages/slonik/src/index.ts | 15 +- .../__test__/resultParser.test.ts | 60 + .../interceptors/fieldNameCaseConverter.ts | 4 +- .../slonik/src/interceptors/resultParser.ts | 6 +- packages/slonik/src/migrate.ts | 3 +- packages/slonik/src/migrationPlugin.ts | 7 +- .../__test__/queryToCreateExtensions.test.ts | 25 + .../migrations/__test__/runMigrations.test.ts | 92 + .../src/migrations/queryToCreateExtensions.ts | 4 +- .../slonik/src/migrations/runMigrations.ts | 6 +- packages/slonik/src/plugin.ts | 9 +- packages/slonik/src/service.ts | 199 +- packages/slonik/src/slonik.ts | 13 +- packages/slonik/src/sql.ts | 13 +- packages/slonik/src/sqlFactory.ts | 199 +- .../__test__/createBigintTypeParser.test.ts | 28 + packages/slonik/src/types/config.ts | 4 +- packages/slonik/src/types/database.ts | 44 +- packages/slonik/src/types/index.ts | 2 +- packages/slonik/src/types/service.ts | 21 +- packages/slonik/src/types/sqlFactory.ts | 17 +- packages/slonik/tsconfig.json | 10 +- packages/slonik/vite.config.ts | 3 +- packages/swagger/FEATURES.md | 31 + packages/swagger/GUIDE.md | 390 ++ packages/swagger/README.md | 32 +- packages/swagger/__test__/plugin.test.ts | 70 - packages/swagger/package.json | 20 +- packages/swagger/src/__test__/plugin.test.ts | 155 + packages/swagger/src/plugin.ts | 3 +- packages/swagger/tsconfig.json | 10 +- packages/swagger/vite.config.ts | 7 +- packages/user/FEATURES.md | 198 + packages/user/GUIDE.md | 827 +++ packages/user/README.md | 63 +- packages/user/package.json | 56 +- packages/user/src/__test__/constants.spec.ts | 217 + packages/user/src/__test__/plugin.test.ts | 361 ++ packages/user/src/constants.ts | 6 +- packages/user/src/graphql/schema.ts | 2 +- packages/user/src/index.ts | 70 +- .../computeInvitationExpiresAt.spec.ts | 79 + .../lib/__test__/getInvitationLink.spec.ts | 121 + .../lib/__test__/hasUserPermission.spec.ts | 142 + .../lib/__test__/isInvitationValid.spec.ts | 70 + .../user/src/lib/__test__/seedRoles.spec.ts | 66 + .../src/lib/computeInvitationExpiresAt.ts | 4 +- packages/user/src/lib/getInvitationLink.ts | 5 +- packages/user/src/lib/getInvitationService.ts | 4 +- packages/user/src/lib/getUserService.ts | 4 +- packages/user/src/lib/hasUserPermission.ts | 4 +- packages/user/src/lib/seedRoles.ts | 2 +- packages/user/src/lib/sendEmail.ts | 4 +- packages/user/src/lib/sendInvitation.ts | 9 +- .../user/src/mercurius-auth/authPlugin.ts | 8 +- .../src/mercurius-auth/hasPermissionPlugin.ts | 6 +- packages/user/src/mercurius-auth/plugin.ts | 4 +- .../__test__/hasPermission.spec.ts | 92 + .../user/src/middlewares/hasPermission.ts | 10 +- packages/user/src/migrations/queries.ts | 8 +- packages/user/src/migrations/runMigrations.ts | 4 +- .../user/src/model/invitations/controller.ts | 24 +- .../src/model/invitations/graphql/resolver.ts | 81 +- .../invitations/handlers/acceptInvitation.ts | 9 +- .../invitations/handlers/createInvitation.ts | 9 +- .../invitations/handlers/deleteInvitation.ts | 7 +- .../handlers/getInvitationByToken.ts | 4 +- .../invitations/handlers/listInvitation.ts | 11 +- .../invitations/handlers/resendInvitation.ts | 13 +- .../invitations/handlers/revokeInvitation.ts | 9 +- packages/user/src/model/invitations/schema.ts | 146 +- .../user/src/model/invitations/service.ts | 35 +- .../user/src/model/invitations/sqlFactory.ts | 14 +- .../user/src/model/permissions/controller.ts | 6 +- .../user/src/model/permissions/resolver.ts | 4 +- packages/user/src/model/permissions/schema.ts | 8 +- packages/user/src/model/roles/controller.ts | 6 +- .../user/src/model/roles/graphql/resolver.ts | 42 +- .../src/model/roles/handlers/createRole.ts | 10 +- .../src/model/roles/handlers/deleteRole.ts | 6 +- .../model/roles/handlers/getPermissions.ts | 4 +- .../user/src/model/roles/handlers/getRoles.ts | 4 +- .../user/src/model/roles/handlers/index.ts | 4 +- .../model/roles/handlers/updatePermissions.ts | 10 +- packages/user/src/model/roles/schema.ts | 78 +- packages/user/src/model/roles/service.ts | 10 +- .../__test__/filterUserUpdateInput.spec.ts | 100 +- packages/user/src/model/users/controller.ts | 36 +- packages/user/src/model/users/dbFilters.ts | 10 +- .../src/model/users/filterUserUpdateInput.ts | 4 +- .../user/src/model/users/graphql/resolver.ts | 383 +- .../src/model/users/handlers/adminSignUp.ts | 14 +- .../model/users/handlers/canAdminSignUp.ts | 4 +- .../src/model/users/handlers/changeEmail.ts | 19 +- .../model/users/handlers/changePassword.ts | 9 +- .../user/src/model/users/handlers/deleteMe.ts | 6 +- .../user/src/model/users/handlers/disable.ts | 4 +- .../user/src/model/users/handlers/enable.ts | 4 +- packages/user/src/model/users/handlers/me.ts | 6 +- .../src/model/users/handlers/removePhoto.ts | 6 +- .../user/src/model/users/handlers/updateMe.ts | 11 +- .../src/model/users/handlers/uploadPhoto.ts | 11 +- .../user/src/model/users/handlers/user.ts | 4 +- .../user/src/model/users/handlers/users.ts | 8 +- packages/user/src/model/users/schema.ts | 158 +- packages/user/src/model/users/service.ts | 116 +- packages/user/src/model/users/sql.ts | 6 +- packages/user/src/model/users/sqlFactory.ts | 18 +- packages/user/src/plugin.ts | 6 +- packages/user/src/schemas/password.ts | 10 +- packages/user/src/supertokens/init.ts | 4 +- packages/user/src/supertokens/plugin.ts | 4 +- .../sendEmailVerificationEmail.ts | 16 +- .../config/emailVerificationRecipeConfig.ts | 21 +- .../config/session/createNewSession.ts | 6 +- .../session/getGlobalClaimValidators.ts | 6 +- .../recipes/config/session/getSession.ts | 4 +- .../recipes/config/sessionRecipeConfig.ts | 15 +- .../emailPasswordSignIn.ts | 9 +- .../emailPasswordSignUp.ts | 15 +- .../emailPasswordSignUpPost.ts | 6 +- .../getFormFields.ts | 6 +- .../resetPasswordUsingToken.ts | 12 +- .../sendPasswordResetEmail.ts | 18 +- .../thirdPartySignInUp.ts | 13 +- .../thirdPartySignInUpPost.ts | 8 +- .../thirdPartyEmailPasswordRecipeConfig.ts | 63 +- .../recipes/config/thirdPartyProviders.ts | 12 +- .../user/src/supertokens/recipes/index.ts | 6 +- .../recipes/initEmailVerificationRecipe.ts | 7 +- .../supertokens/recipes/initSessionRecipe.ts | 7 +- .../initThirdPartyEmailPasswordRecipe.ts | 7 +- .../recipes/initUserRolesRecipe.ts | 7 +- .../types/emailVerificationRecipe.ts | 26 +- packages/user/src/supertokens/types/index.ts | 53 +- .../src/supertokens/types/sessionRecipe.ts | 22 +- .../types/thirdPartyEmailPasswordRecipe.ts | 14 +- .../__test__/profileValidationClaim.spec.ts | 214 + .../supertokens/utils/createUserContext.ts | 4 +- .../utils/profileValidationClaim.ts | 106 +- packages/user/src/types/config.ts | 19 +- packages/user/src/types/index.ts | 20 +- packages/user/src/types/invitation.ts | 12 +- .../user/src/types/strongPasswordOptions.ts | 44 +- packages/user/src/types/user.ts | 16 +- packages/user/src/userContext.ts | 8 +- .../user/src/validator/__test__/email.spec.ts | 17 +- .../src/validator/__test__/password.spec.ts | 12 +- packages/user/src/validator/email.ts | 4 +- packages/user/src/validator/password.ts | 13 +- packages/user/vite.config.ts | 9 +- packages/worker/ANALYSIS.md | 131 + packages/worker/FEATURES.md | 69 + packages/worker/GUIDE.md | 403 ++ packages/worker/README.md | 126 +- packages/worker/package.json | 42 +- .../src/__test__/cron/scheduler.test.ts | 6 +- .../src/__test__/jobOrchestrator.test.ts | 79 +- .../src/__test__/plugin.integration.test.ts | 42 + packages/worker/src/__test__/plugin.test.ts | 18 +- .../__test__/queue/adapterRegistry.test.ts | 8 +- .../__test__/queue/adapters/bullmq.test.ts | 43 +- .../src/__test__/queue/adapters/sqs.test.ts | 263 +- packages/worker/src/enum/index.ts | 2 +- packages/worker/src/index.ts | 16 +- packages/worker/src/jobOrchestrator.ts | 15 +- packages/worker/src/queue/adapterRegistry.ts | 4 +- packages/worker/src/queue/adapters/base.ts | 4 +- packages/worker/src/queue/adapters/bullmq.ts | 66 +- packages/worker/src/queue/adapters/index.ts | 4 +- packages/worker/src/queue/adapters/sqs.ts | 168 +- packages/worker/src/queue/factory.ts | 10 +- packages/worker/src/queue/index.ts | 4 +- packages/worker/src/types/cron.ts | 2 +- packages/worker/src/types/queue.ts | 6 +- packages/worker/vite.config.ts | 5 +- pnpm-lock.yaml | 3995 ++++++------ renovate.json | 4 +- ship.config.js | 17 +- turbo.json | 34 +- 341 files changed, 23106 insertions(+), 11694 deletions(-) create mode 100644 .claude/skills/CONVENTIONS.md create mode 100644 packages/config/ADR-CONFIG.md create mode 100644 packages/config/FEATURES.md create mode 100644 packages/config/GUIDE.md create mode 100644 packages/config/src/__test__/plugin.test.ts create mode 100644 packages/error-handler/FEATURES.md create mode 100644 packages/error-handler/GUIDE.md create mode 100644 packages/error-handler/src/__test__/domainStatusMap.test.ts create mode 100644 packages/error-handler/src/__test__/errorHandlerExport.test.ts create mode 100644 packages/error-handler/src/__test__/errorHandling.test.ts create mode 100644 packages/error-handler/src/__test__/helpers.ts create mode 100644 packages/error-handler/src/__test__/httpErrors.test.ts create mode 100644 packages/error-handler/src/__test__/logging.test.ts create mode 100644 packages/error-handler/src/__test__/masking.test.ts create mode 100644 packages/error-handler/src/__test__/preErrorHandler.test.ts create mode 100644 packages/error-handler/src/__test__/registration.test.ts create mode 100644 packages/error-handler/src/__test__/stackTrace.test.ts create mode 100644 packages/firebase/FEATURES.md create mode 100644 packages/firebase/GUIDE.md delete mode 100644 packages/firebase/__test__/service.spec.ts create mode 100644 packages/firebase/src/__test__/controllers.test.ts create mode 100644 packages/firebase/src/__test__/handlers.test.ts create mode 100644 packages/firebase/src/__test__/helpers/createConfig.ts create mode 100644 packages/firebase/src/__test__/helpers/createDatabase.ts create mode 100644 packages/firebase/src/__test__/initializeFirebase.test.ts create mode 100644 packages/firebase/src/__test__/isFirebaseEnabled.test.ts create mode 100644 packages/firebase/src/__test__/libraryAndMigrations.test.ts create mode 100644 packages/firebase/src/__test__/notificationResolver.test.ts create mode 100644 packages/firebase/src/__test__/plugin.test.ts create mode 100644 packages/firebase/src/__test__/queries.test.ts create mode 100644 packages/firebase/src/__test__/service.test.ts create mode 100644 packages/firebase/src/__test__/sqlFactory.test.ts create mode 100644 packages/firebase/src/__test__/userDeviceResolver.test.ts create mode 100644 packages/graphql/FEATURES.md create mode 100644 packages/graphql/GUIDE.md create mode 100644 packages/graphql/src/__test__/baseSchema.test.ts create mode 100644 packages/graphql/src/__test__/plugin.test.ts create mode 100644 packages/mailer/ANALYSIS.md create mode 100644 packages/mailer/FEATURES.md create mode 100644 packages/mailer/GUIDE.md delete mode 100644 packages/mailer/src/__test__/mailer.spec.ts create mode 100644 packages/mailer/src/__test__/recipients.test.ts create mode 100644 packages/mailer/src/__test__/registration.test.ts create mode 100644 packages/mailer/src/__test__/schema.test.ts create mode 100644 packages/mailer/src/__test__/sendMail.test.ts create mode 100644 packages/mailer/src/__test__/testRoute.test.ts create mode 100644 packages/s3/FEATURES.md create mode 100644 packages/s3/GUIDE.md create mode 100644 packages/s3/src/__test__/multipartParser.test.ts create mode 100644 packages/s3/src/__test__/plugin.test.ts create mode 100644 packages/s3/src/__test__/s3Client.test.ts create mode 100644 packages/s3/src/__test__/service.test.ts create mode 100644 packages/s3/src/__test__/sqlFactory.test.ts create mode 100644 packages/s3/src/__test__/utils.test.ts create mode 100644 packages/s3/src/plugins/__test__/graphqlUpload.test.ts create mode 100644 packages/slonik/FEATURES.md create mode 100644 packages/slonik/GUIDE.md create mode 100644 packages/slonik/feature.md delete mode 100644 packages/slonik/package-lock.json create mode 100644 packages/slonik/src/__test__/migrate.test.ts create mode 100644 packages/slonik/src/__test__/migrationPlugin.test.ts create mode 100644 packages/slonik/src/__test__/plugin.test.ts create mode 100644 packages/slonik/src/__test__/sqlFactory.test.ts create mode 100644 packages/slonik/src/interceptors/__test__/resultParser.test.ts create mode 100644 packages/slonik/src/migrations/__test__/queryToCreateExtensions.test.ts create mode 100644 packages/slonik/src/migrations/__test__/runMigrations.test.ts create mode 100644 packages/slonik/src/typeParsers/__test__/createBigintTypeParser.test.ts create mode 100644 packages/swagger/FEATURES.md create mode 100644 packages/swagger/GUIDE.md delete mode 100644 packages/swagger/__test__/plugin.test.ts create mode 100644 packages/swagger/src/__test__/plugin.test.ts create mode 100644 packages/user/FEATURES.md create mode 100644 packages/user/GUIDE.md create mode 100644 packages/user/src/__test__/constants.spec.ts create mode 100644 packages/user/src/__test__/plugin.test.ts create mode 100644 packages/user/src/lib/__test__/computeInvitationExpiresAt.spec.ts create mode 100644 packages/user/src/lib/__test__/getInvitationLink.spec.ts create mode 100644 packages/user/src/lib/__test__/hasUserPermission.spec.ts create mode 100644 packages/user/src/lib/__test__/isInvitationValid.spec.ts create mode 100644 packages/user/src/lib/__test__/seedRoles.spec.ts create mode 100644 packages/user/src/middlewares/__test__/hasPermission.spec.ts create mode 100644 packages/user/src/supertokens/utils/__test__/profileValidationClaim.spec.ts create mode 100644 packages/worker/ANALYSIS.md create mode 100644 packages/worker/FEATURES.md create mode 100644 packages/worker/GUIDE.md create mode 100644 packages/worker/src/__test__/plugin.integration.test.ts diff --git a/.claude/skills/CONVENTIONS.md b/.claude/skills/CONVENTIONS.md new file mode 100644 index 000000000..3e57a19fe --- /dev/null +++ b/.claude/skills/CONVENTIONS.md @@ -0,0 +1,44 @@ +# Shared Conventions + +Rules that apply across all skills. Each SKILL.md references this file instead of repeating these. + +--- + +## Scope + +- **Stay inside the target package directory.** Do not read files outside of it unless explicitly told to. +- **Do not invent features.** Only document or test what source code confirms exists. + +## Code Examples + +- Use **TypeScript** in all code examples. +- Keep examples minimal — just enough to show the point, no boilerplate. + +## Testing + +- **Use real Fastify instances. Do NOT mock Fastify.** Plugins are side-effect functions — mocking the instance means testing nothing. +- **Do NOT mock base-library plugins** (e.g., `@fastify/swagger`, `@fastify/multipart`). The point of the integration layer tests is to catch breakage from dependency upgrades. Mock only our own modules (migrations, sub-plugins we authored). +- **Always close Fastify instances in `afterEach`** to avoid resource leaks. +- **Use Vitest** (`import from "vitest"`). The project already has it configured. +- **Test what WE wrote, not what third-party libraries do.** Ask: "Does this verify code our team wrote, or that a third-party library works?" +- **Name tests by behavior**, not implementation. GOOD: `"decorates instance with default documentation path"` BAD: `"line 23 sets routePrefix"` + +### Known Fastify 5 Gotchas + +These patterns have been validated in this monorepo. Follow them to avoid known pitfalls: + +1. **`hasContentTypeParser("*")` returns false** even when a `*` catch-all parser is registered in Fastify 5. Use a behavioural test instead: inject a request with an unusual content-type and assert the status is not 415. +2. **Asserting `vi.fn()` plugin calls**: always include `expect.any(Function)` as the third argument — Fastify calls plugins as `plugin(fastify, options, done)`. +3. **`Readable.from(["string"])` emits strings, not Buffers.** `Buffer.concat` will throw. Use `Readable.from([Buffer.from("string")])` instead. +4. **Verify `@fastify/multipart`** with `fastify.hasContentTypeParser("multipart/form-data")`, not `getSchema("fileSchema")` — `sharedSchemaId` does not expose a schema via `fastify.getSchema`. + +## Base Library Documentation + +- **Do not repeat base library documentation in detail.** Link to their docs. +- **For doc links:** use the library's official docs URL. If unsure, use the npm page: `https://www.npmjs.com/package/{package-name}`. +- **List only the delta** for partial/modified passthroughs — what we change, not what we preserve. + +## Reference Packages + +- `packages/firebase` — has FEATURES.md and comprehensive tests +- `packages/config` — has GUIDE.md as the format reference diff --git a/.commitlintrc.json b/.commitlintrc.json index 0df1d2536..c30e5a970 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -1,5 +1,3 @@ { - "extends": [ - "@commitlint/config-conventional" - ] + "extends": ["@commitlint/config-conventional"] } diff --git a/.github/workflows/shipjs-trigger.yml b/.github/workflows/shipjs-trigger.yml index 12aa716a4..8e944f9e2 100644 --- a/.github/workflows/shipjs-trigger.yml +++ b/.github/workflows/shipjs-trigger.yml @@ -14,18 +14,16 @@ jobs: with: fetch-depth: 0 ref: main + - name: Setup pnpm + uses: pnpm/action-setup@v4 - name: Setup node 24 uses: actions/setup-node@v4 with: node-version: 24 registry-url: "https://registry.npmjs.org" cache: "pnpm" - - name: Setup pnpm and install dependencies - uses: pnpm/action-setup@v4 - with: - run_install: | - - recursive: true - args: [--frozen-lockfile, --strict-peer-dependencies] + - name: Install dependencies + run: pnpm install --frozen-lockfile --strict-peer-dependencies - run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 323d137d7..2b87a32c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,6 @@ name: "Test suite" -on: - push +on: push jobs: test: diff --git a/.gitignore b/.gitignore index d7f019b08..f8b7ac27c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ **/dist **/*.log* **/node_modules +**/skills +**/skills.old/ .turbo diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2f72b6b..b83362962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,57 +1,71 @@ -## [0.93.5](https://github.com/prefabs-tech/fastify/compare/v0.93.4...v0.93.5) (2026-02-11) +# [0.94.0](https://github.com/prefabs-tech/fastify/compare/v0.93.5...v0.94.0) (2026-05-07) +### Features +- **error-handler:** add optional `domainErrorStatusMap` (`Map`) so non-`HttpError` errors can map `error.name` to HTTP status codes `400`–`599`; standalone `errorHandler` accepts the same options via its 4th argument ([#1097](https://github.com/prefabs-tech/fastify/issues/1097)) ([7acc7b0](https://github.com/prefabs-tech/fastify/commit/7acc7b07024a33b85ab94960833b1a194c94ff84)) -## [0.93.4](https://github.com/prefabs-tech/fastify/compare/v0.93.3...v0.93.4) (2026-01-15) +Example: +```typescript +const config: ApiConfig = { + errorHandler: { + domainErrorStatusMap: new Map([ + [Exception.UNPROCESSABLE_ENTITY_ERROR, 422], + [ThingException.THING_NOT_FOUND, 404], + ]), + }, +}; -### Bug Fixes +await fastify.register(errorHandlerPlugin, config.errorHandler); +``` -* **user:** fix error handling on create invitation handler ([#1048](https://github.com/prefabs-tech/fastify/issues/1048)) ([a915fca](https://github.com/prefabs-tech/fastify/commit/a915fcaafedce02a0df55fe265e9c9a610baeca5)) +Thrown domain errors must set **`error.name`** to one of the map keys (for example `Exception.UNPROCESSABLE_ENTITY_ERROR` or `ThingException.THING_NOT_FOUND`). +### Bug Fixes +- **firebase:** upgrade `firebase-admin` to 13.7.0 to address CVE-2026-25896 (XSS via transitive `fast-xml-parser`) ([021c207](https://github.com/prefabs-tech/fastify/commit/021c20714ea5e746cb11bf8398507671cf2cbf5b)) -## [0.93.3](https://github.com/prefabs-tech/fastify/compare/v0.93.2...v0.93.3) (2026-01-06) +### Tooling +- **shipjs:** installed pnpm before using it on CI. +- **shipjs:** fixed config +## [0.93.5](https://github.com/prefabs-tech/fastify/compare/v0.93.4...v0.93.5) (2026-02-11) -## [0.93.2](https://github.com/prefabs-tech/fastify/compare/v0.93.1...v0.93.2) (2025-12-18) +## [0.93.4](https://github.com/prefabs-tech/fastify/compare/v0.93.3...v0.93.4) (2026-01-15) +### Bug Fixes +- **user:** fix error handling on create invitation handler ([#1048](https://github.com/prefabs-tech/fastify/issues/1048)) ([a915fca](https://github.com/prefabs-tech/fastify/commit/a915fcaafedce02a0df55fe265e9c9a610baeca5)) -## [0.93.1](https://github.com/prefabs-tech/fastify/compare/v0.93.0...v0.93.1) (2025-12-09) +## [0.93.3](https://github.com/prefabs-tech/fastify/compare/v0.93.2...v0.93.3) (2026-01-06) +## [0.93.2](https://github.com/prefabs-tech/fastify/compare/v0.93.1...v0.93.2) (2025-12-18) +## [0.93.1](https://github.com/prefabs-tech/fastify/compare/v0.93.0...v0.93.1) (2025-12-09) # [0.93.0](https://github.com/prefabs-tech/fastify/compare/v0.92.2...v0.93.0) (2025-11-05) - ### Features -* **slonik**: add support for insensitive (case and accent) filter and sort ([#940](https://github.com/prefabs-tech/fastify/issues/940)) ([c5ac3a2](https://github.com/prefabs-tech/fastify/commit/c5ac3a22977212191fe46d82ad97395ea6e64cc4)) - - +- **slonik**: add support for insensitive (case and accent) filter and sort ([#940](https://github.com/prefabs-tech/fastify/issues/940)) ([c5ac3a2](https://github.com/prefabs-tech/fastify/commit/c5ac3a22977212191fe46d82ad97395ea6e64cc4)) ## [0.92.2](https://github.com/prefabs-tech/fastify/compare/v0.92.1...v0.92.2) (2025-10-30) - ### Bug Fixes -* **firebase:** fix firebase-admin import style to support commonjs and ESM ([#1033](https://github.com/prefabs-tech/fastify/issues/1033)) ([b024dee](https://github.com/prefabs-tech/fastify/commit/b024deecf7ae26f215eb3aaa6bc17f8fbf21bc3b)) - - +- **firebase:** fix firebase-admin import style to support commonjs and ESM ([#1033](https://github.com/prefabs-tech/fastify/issues/1033)) ([b024dee](https://github.com/prefabs-tech/fastify/commit/b024deecf7ae26f215eb3aaa6bc17f8fbf21bc3b)) ## [0.92.1](https://github.com/prefabs-tech/fastify/compare/v0.92.0...v0.92.1) (2025-10-27) - - # [0.92.0](https://github.com/prefabs-tech/fastify/compare/v0.91.1...v0.92.0) (2025-09-26) ### BREAKING CHANGES -* **s3:** update s3 config, AWS S3 client config (access key, secret key etc) are now should be passed into clientConfig +- **s3:** update s3 config, AWS S3 client config (access key, secret key etc) are now should be passed into clientConfig Example: + ```typescript const config: ApiConfig = { // ... other configurations @@ -68,9 +82,10 @@ const config: ApiConfig = { } ``` -* **s3:** UplaodedById is now optional in files model +- **s3:** UplaodedById is now optional in files model ##### Required Migration + If you're upgrading to this version, run the following SQL migration: ```sql @@ -78,15 +93,11 @@ ALTER TABLE files ALTER COLUMN "uploaded_by_id" DROP NOT NULL; ``` - ## [0.91.1](https://github.com/prefabs-tech/fastify/compare/v0.91.0...v0.91.1) (2025-09-23) - ### Bug Fixes -* **slonik:** fix incorrect latitude-longitude order in dwithin filter ([#1023](https://github.com/prefabs-tech/fastify/issues/1023)) ([4f403fb](https://github.com/prefabs-tech/fastify/commit/4f403fbe5a7013169f5052c11661770fd16727f7)) - - +- **slonik:** fix incorrect latitude-longitude order in dwithin filter ([#1023](https://github.com/prefabs-tech/fastify/issues/1023)) ([4f403fb](https://github.com/prefabs-tech/fastify/commit/4f403fbe5a7013169f5052c11661770fd16727f7)) # [0.91.0](https://github.com/prefabs-tech/fastify/compare/v0.90.2...v0.91.0) (2025-09-18) @@ -99,13 +110,13 @@ They are now declared as **peer dependencies**, which means you must install and #### Required Changes -* Install the missing dependencies: +- Install the missing dependencies: ```bash npm install @fastify/cors @fastify/formbody ``` -* Update your server setup: +- Update your server setup: ```typescript import corsPlugin from "@fastify/cors"; @@ -132,7 +143,7 @@ const start = async () => { await fastify.register(formBodyPlugin); ... - + await fastify.listen({ port: config.port, host: "0.0.0.0", @@ -142,28 +153,23 @@ const start = async () => { start(); ``` - ## [0.90.2](https://github.com/prefabs-tech/fastify/compare/v0.90.1...v0.90.2) (2025-09-05) - - ## [0.90.1](https://github.com/prefabs-tech/fastify/compare/v0.90.0...v0.90.1) (2025-08-25) - - # [0.90.0](https://github.com/prefabs-tech/fastify/compare/v0.89.2...v0.90.0) (2025-08-21) ### Breaking changes -* @prefabs.tech/fastify-firebase, @prefabs.tech/fastify-s3, @prefabs.tech/fastify-user now requires @prefabs.tech/fastify-error-handler package for handling http errors and other custom errors. +- @prefabs.tech/fastify-firebase, @prefabs.tech/fastify-s3, @prefabs.tech/fastify-user now requires @prefabs.tech/fastify-error-handler package for handling http errors and other custom errors. ### Bug Fixes -* **user/photo:** fix photo upload ([#1005](https://github.com/prefabs-tech/fastify/issues/1005)) ([f32a4e0](https://github.com/prefabs-tech/fastify/commit/f32a4e06e6947f5c1a0b3ddeb503d4660225de94)) +- **user/photo:** fix photo upload ([#1005](https://github.com/prefabs-tech/fastify/issues/1005)) ([f32a4e0](https://github.com/prefabs-tech/fastify/commit/f32a4e06e6947f5c1a0b3ddeb503d4660225de94)) -* **user/invitation:** create invitaion endpoint now check if role is exists or not, if not exist throws error. +- **user/invitation:** create invitaion endpoint now check if role is exists or not, if not exist throws error. -* **error-handler** fix ErrorResponse fastify schema and update error handler. +- **error-handler** fix ErrorResponse fastify schema and update error handler. ### Error Handling Guidelines @@ -176,20 +182,21 @@ Instead, always throw an error and let the global error handler handle formattin **Wrong** ```ts -fastify.get('/test', async (req, reply) => { +fastify.get("/test", async (req, reply) => { return reply.code(401).send({ message: "Unauthorized" }); -}) +}); ``` **Correct** ```ts -fastify.get('/test', async (req, reply) => { +fastify.get("/test", async (req, reply) => { throw fastify.httpErrors.unauthorized("Unauthorized"); -}) +}); ``` #### Throw `CustomError` (or subclass) + - Modules **must throw** an instance of `CustomError` (or a class extending it). - This ensures errors can be consistently caught and appropriate actions taken. @@ -205,49 +212,33 @@ if (!file) { ## [0.89.2](https://github.com/prefabs-tech/fastify/compare/v0.89.1...v0.89.2) (2025-08-15) - - ## [0.89.1](https://github.com/prefabs-tech/fastify/compare/v0.89.0...v0.89.1) (2025-08-13) - ### Bug Fixes -* **error-handler:** replace stack-trace package with stacktracey to support commonjs ([#1009](https://github.com/prefabs-tech/fastify/issues/1009)) ([05cdb03](https://github.com/prefabs-tech/fastify/commit/05cdb03351066da193d4519183624dea4768c1ca)) - - +- **error-handler:** replace stack-trace package with stacktracey to support commonjs ([#1009](https://github.com/prefabs-tech/fastify/issues/1009)) ([05cdb03](https://github.com/prefabs-tech/fastify/commit/05cdb03351066da193d4519183624dea4768c1ca)) # [0.89.0](https://github.com/prefabs-tech/fastify/compare/v0.88.2...v0.89.0) (2025-08-12) - ### Features -* **error-handler:** Add error handler package ([#1004](https://github.com/prefabs-tech/fastify/issues/1004)) ([aae5dbd](https://github.com/prefabs-tech/fastify/commit/aae5dbdf6718bd575033df9cfda0d40e10a85aae)) - - +- **error-handler:** Add error handler package ([#1004](https://github.com/prefabs-tech/fastify/issues/1004)) ([aae5dbd](https://github.com/prefabs-tech/fastify/commit/aae5dbdf6718bd575033df9cfda0d40e10a85aae)) ## [0.88.2](https://github.com/prefabs-tech/fastify/compare/v0.88.1...v0.88.2) (2025-07-31) - ### Bug Fixes -* **user/photo:** fix photo upload ([#1005](https://github.com/prefabs-tech/fastify/issues/1005)) ([f32a4e0](https://github.com/prefabs-tech/fastify/commit/f32a4e06e6947f5c1a0b3ddeb503d4660225de94)) - - +- **user/photo:** fix photo upload ([#1005](https://github.com/prefabs-tech/fastify/issues/1005)) ([f32a4e0](https://github.com/prefabs-tech/fastify/commit/f32a4e06e6947f5c1a0b3ddeb503d4660225de94)) ## [0.88.1](https://github.com/prefabs-tech/fastify/compare/v0.88.0...v0.88.1) (2025-07-28) - ### Features -* **user:** add photo file size limit in user package ([#1000](https://github.com/prefabs-tech/fastify/issues/1000)) ([5de43a8](https://github.com/prefabs-tech/fastify/commit/5de43a8e2a588c6fba61684b1927c3dd0a3dc8a9)) -* disable email verifation validation for update me route ([de4deb9](https://github.com/prefabs-tech/fastify/commit/de4deb954dce87586bef5c77a742ec2e2ef9bdad)) - - +- **user:** add photo file size limit in user package ([#1000](https://github.com/prefabs-tech/fastify/issues/1000)) ([5de43a8](https://github.com/prefabs-tech/fastify/commit/5de43a8e2a588c6fba61684b1927c3dd0a3dc8a9)) +- disable email verifation validation for update me route ([de4deb9](https://github.com/prefabs-tech/fastify/commit/de4deb954dce87586bef5c77a742ec2e2ef9bdad)) # [0.88.0](https://github.com/prefabs-tech/fastify/compare/v0.87.0...v0.88.0) (2025-07-24) - - > ⚠️ This package was migrated from [`@dzangolab/fastify`](https://github.com/dzangolab/fastify) to [`@prefabs.tech/fastify`](https://github.com/prefabs-tech/fastify). All previous links will redirect. # [0.87.0](https://github.com/dzangolab/fastify/compare/v0.86.1...v0.87.0) (2025-07-21) @@ -261,6 +252,7 @@ if (!file) { - add `s3.bucket` config to upload user photo to desired bucket. if the bucket is not specified the photo won't get uploaded. ##### Required Migration + If you're upgrading to this version, run the following SQL migration: ```sql @@ -273,102 +265,71 @@ ADD CONSTRAINT fk_users_photo ## [0.86.1](https://github.com/dzangolab/fastify/compare/v0.86.0...v0.86.1) (2025-07-16) - - # [0.86.0](https://github.com/dzangolab/fastify/compare/v0.85.1...v0.86.0) (2025-07-07) - ### Features -* add support for dwithin filter ([#984](https://github.com/dzangolab/fastify/issues/984)) ([0c179ab](https://github.com/dzangolab/fastify/commit/0c179abe2945384cec826b3dff35acfc0b00c67b)) - - +- add support for dwithin filter ([#984](https://github.com/dzangolab/fastify/issues/984)) ([0c179ab](https://github.com/dzangolab/fastify/commit/0c179abe2945384cec826b3dff35acfc0b00c67b)) ## [0.85.1](https://github.com/dzangolab/fastify/compare/v0.85.0...v0.85.1) (2025-07-04) - ### Bug Fixes -* **deps:** update dependency @fastify/swagger-ui to v5.2.3 ([#972](https://github.com/dzangolab/fastify/issues/972)) ([5fb3e88](https://github.com/dzangolab/fastify/commit/5fb3e884d1f0c8f6a771157b5d84c3c47ed66a84)) - - +- **deps:** update dependency @fastify/swagger-ui to v5.2.3 ([#972](https://github.com/dzangolab/fastify/issues/972)) ([5fb3e88](https://github.com/dzangolab/fastify/commit/5fb3e884d1f0c8f6a771157b5d84c3c47ed66a84)) # [0.85.0](https://github.com/dzangolab/fastify/compare/v0.84.4...v0.85.0) (2025-07-02) - ### Features -* **slonik:** support hooks in slonik service ([#978](https://github.com/dzangolab/fastify/issues/978)) ([82a42f5](https://github.com/dzangolab/fastify/commit/82a42f5906f60a15faefdb23801dffe5b1be4047)) - - +- **slonik:** support hooks in slonik service ([#978](https://github.com/dzangolab/fastify/issues/978)) ([82a42f5](https://github.com/dzangolab/fastify/commit/82a42f5906f60a15faefdb23801dffe5b1be4047)) ## [0.84.4](https://github.com/dzangolab/fastify/compare/v0.84.3...v0.84.4) (2025-06-20) - ### Bug Fixes -* **deps:** update dependency zod to v3.25.67 ([#963](https://github.com/dzangolab/fastify/issues/963)) ([a544c6b](https://github.com/dzangolab/fastify/commit/a544c6b39ef838f374946ad5657abee113abc06b)) - +- **deps:** update dependency zod to v3.25.67 ([#963](https://github.com/dzangolab/fastify/issues/963)) ([a544c6b](https://github.com/dzangolab/fastify/commit/a544c6b39ef838f374946ad5657abee113abc06b)) ### Features -* add support for filter and sort in joined table column ([#970](https://github.com/dzangolab/fastify/issues/970)) ([c87438c](https://github.com/dzangolab/fastify/commit/c87438cd200a1338a4fd0fc2daa9e105e6d219d9)) - - +- add support for filter and sort in joined table column ([#970](https://github.com/dzangolab/fastify/issues/970)) ([c87438c](https://github.com/dzangolab/fastify/commit/c87438cd200a1338a4fd0fc2daa9e105e6d219d9)) ## [0.84.3](https://github.com/dzangolab/fastify/compare/v0.84.2...v0.84.3) (2025-06-09) - ### Bug Fixes -* **deps:** update aws-sdk-js-v3 monorepo to v3.815.0 ([#895](https://github.com/dzangolab/fastify/issues/895)) ([6796400](https://github.com/dzangolab/fastify/commit/679640090958c5c39a44b787ac628b7315568b6e)) -* **deps:** update dependency @graphql-tools/merge to v9.0.24 ([#956](https://github.com/dzangolab/fastify/issues/956)) ([682a722](https://github.com/dzangolab/fastify/commit/682a722c71bd94c09758c6fc032dc760db1154d5)) -* **deps:** update dependency nodemailer to v6.10.1 ([#958](https://github.com/dzangolab/fastify/issues/958)) ([07af2b9](https://github.com/dzangolab/fastify/commit/07af2b91d030cf814980da2d0d8f857e2acf6935)) -* **deps:** update dependency slonik-interceptor-query-logging to v46.8.0 ([#897](https://github.com/dzangolab/fastify/issues/897)) ([e6f9927](https://github.com/dzangolab/fastify/commit/e6f9927a268ef7f2e61fe18532ce17aa5ea34bdc)) -* fix creating filter fragment for complex nested filter input ([#967](https://github.com/dzangolab/fastify/issues/967)) ([1ff663c](https://github.com/dzangolab/fastify/commit/1ff663c1860b69753ed9bd68d8603bbcf7fcc5c2)) - - +- **deps:** update aws-sdk-js-v3 monorepo to v3.815.0 ([#895](https://github.com/dzangolab/fastify/issues/895)) ([6796400](https://github.com/dzangolab/fastify/commit/679640090958c5c39a44b787ac628b7315568b6e)) +- **deps:** update dependency @graphql-tools/merge to v9.0.24 ([#956](https://github.com/dzangolab/fastify/issues/956)) ([682a722](https://github.com/dzangolab/fastify/commit/682a722c71bd94c09758c6fc032dc760db1154d5)) +- **deps:** update dependency nodemailer to v6.10.1 ([#958](https://github.com/dzangolab/fastify/issues/958)) ([07af2b9](https://github.com/dzangolab/fastify/commit/07af2b91d030cf814980da2d0d8f857e2acf6935)) +- **deps:** update dependency slonik-interceptor-query-logging to v46.8.0 ([#897](https://github.com/dzangolab/fastify/issues/897)) ([e6f9927](https://github.com/dzangolab/fastify/commit/e6f9927a268ef7f2e61fe18532ce17aa5ea34bdc)) +- fix creating filter fragment for complex nested filter input ([#967](https://github.com/dzangolab/fastify/issues/967)) ([1ff663c](https://github.com/dzangolab/fastify/commit/1ff663c1860b69753ed9bd68d8603bbcf7fcc5c2)) ## [0.84.2](https://github.com/dzangolab/fastify/compare/v0.84.1...v0.84.2) (2025-05-22) - ### Bug Fixes -* **deps:** update dependency zod to v3.25.20 ([#898](https://github.com/dzangolab/fastify/issues/898)) ([b95b44a](https://github.com/dzangolab/fastify/commit/b95b44aec9fe5c5a640c44c7e323945504a7b6a0)) - +- **deps:** update dependency zod to v3.25.20 ([#898](https://github.com/dzangolab/fastify/issues/898)) ([b95b44a](https://github.com/dzangolab/fastify/commit/b95b44aec9fe5c5a640c44c7e323945504a7b6a0)) ### Features -* add fastify schema for rest routes ([#954](https://github.com/dzangolab/fastify/issues/954)) ([19cc070](https://github.com/dzangolab/fastify/commit/19cc070934728305110ae01983e9a14da9741cdc)) - - +- add fastify schema for rest routes ([#954](https://github.com/dzangolab/fastify/issues/954)) ([19cc070](https://github.com/dzangolab/fastify/commit/19cc070934728305110ae01983e9a14da9741cdc)) ## [0.84.1](https://github.com/dzangolab/fastify/compare/v0.84.0...v0.84.1) (2025-05-19) - ### Features -* **s3:** add ajv file plugin to use @fastify/multipart with @fastify/swagger for body validation ([#952](https://github.com/dzangolab/fastify/issues/952)) ([0bd341d](https://github.com/dzangolab/fastify/commit/0bd341d342bdab2231db79266bfd8947cce2cb55)) - - +- **s3:** add ajv file plugin to use @fastify/multipart with @fastify/swagger for body validation ([#952](https://github.com/dzangolab/fastify/issues/952)) ([0bd341d](https://github.com/dzangolab/fastify/commit/0bd341d342bdab2231db79266bfd8947cce2cb55)) # [0.84.0](https://github.com/dzangolab/fastify/compare/v0.83.0...v0.84.0) (2025-05-14) - ### Features -* **swagger:** add fastify-swagger package ([#950](https://github.com/dzangolab/fastify/issues/950)) ([eddd1ce](https://github.com/dzangolab/fastify/commit/eddd1ceefa14f8d80cd46f0cc385527a4d6851e3)) - - +- **swagger:** add fastify-swagger package ([#950](https://github.com/dzangolab/fastify/issues/950)) ([eddd1ce](https://github.com/dzangolab/fastify/commit/eddd1ceefa14f8d80cd46f0cc385527a4d6851e3)) # [0.83.0](https://github.com/dzangolab/fastify/compare/v0.82.0...v0.83.0) (2025-05-09) - ### Features -* **user:** add deleteMe graphql mutation ([#946](https://github.com/dzangolab/fastify/issues/946)) ([5fbb582](https://github.com/dzangolab/fastify/commit/5fbb58208252b0d7c192e43004292aa5bb958a0e)) - - +- **user:** add deleteMe graphql mutation ([#946](https://github.com/dzangolab/fastify/issues/946)) ([5fbb582](https://github.com/dzangolab/fastify/commit/5fbb58208252b0d7c192e43004292aa5bb958a0e)) # [0.82.0](https://github.com/dzangolab/fastify/compare/v0.81.0...v0.82.0) (2025-05-08) @@ -381,6 +342,7 @@ ADD CONSTRAINT fk_users_photo - This requires a new column deleted_at to be added to the users table (or your custom user table if overridden). ##### Required Migration + If you're upgrading to this version, run the following SQL migration: ```sql @@ -390,35 +352,30 @@ ADD "deleted_at" timestamp NULL; ### Features -* **user:** delete my user account ([#944](https://github.com/dzangolab/fastify/issues/944)) ([ddf6eb2](https://github.com/dzangolab/fastify/commit/ddf6eb2ae778e991cc6d476447515a71440152fe)) -* **user:** support filter in roles column ([#943](https://github.com/dzangolab/fastify/issues/943)) ([1af08d6](https://github.com/dzangolab/fastify/commit/1af08d6f4684f13fc1414c5b13c23de8593ed99f)) - - +- **user:** delete my user account ([#944](https://github.com/dzangolab/fastify/issues/944)) ([ddf6eb2](https://github.com/dzangolab/fastify/commit/ddf6eb2ae778e991cc6d476447515a71440152fe)) +- **user:** support filter in roles column ([#943](https://github.com/dzangolab/fastify/issues/943)) ([1af08d6](https://github.com/dzangolab/fastify/commit/1af08d6f4684f13fc1414c5b13c23de8593ed99f)) # [0.81.0](https://github.com/dzangolab/fastify/compare/v0.80.1...v0.81.0) (2025-04-25) - ### Features -* **slonik:** add soft delete feature ([#931](https://github.com/dzangolab/fastify/issues/931)) ([69a0351](https://github.com/dzangolab/fastify/commit/69a0351fdb4faaf50ce83710129a3db8de051a52)) - +- **slonik:** add soft delete feature ([#931](https://github.com/dzangolab/fastify/issues/931)) ([69a0351](https://github.com/dzangolab/fastify/commit/69a0351fdb4faaf50ce83710129a3db8de051a52)) ### Performance Improvements -* update services and sql factories ([#926](https://github.com/dzangolab/fastify/issues/926)) ([08bc0d1](https://github.com/dzangolab/fastify/commit/08bc0d1c8ef9f9ad98768ffbd2a1e0359f580204)) - +- update services and sql factories ([#926](https://github.com/dzangolab/fastify/issues/926)) ([08bc0d1](https://github.com/dzangolab/fastify/commit/08bc0d1c8ef9f9ad98768ffbd2a1e0359f580204)) ### BREAKING CHANGES -* Removed generic types from SqlFactory. -* Moved database configuration into SqlFactory. Static properties like TABLE, LIMIT_DEFAULT, and SORT_KEY must now be defined inside each factory class. -* Required every entity to have a corresponding SqlFactory (at minimum, to define the TABLE name). -* Converted instance property methods into publicly overridable methods. -* Removed dependency on QueryResultRow -* Updated services to use entity types directly instead of generics. - +- Removed generic types from SqlFactory. +- Moved database configuration into SqlFactory. Static properties like TABLE, LIMIT_DEFAULT, and SORT_KEY must now be defined inside each factory class. +- Required every entity to have a corresponding SqlFactory (at minimum, to define the TABLE name). +- Converted instance property methods into publicly overridable methods. +- Removed dependency on QueryResultRow +- Updated services to use entity types directly instead of generics. #### Example of the new SqlFactory and Service pattern: + ```ts import { DefaultSqlFactory } from "@dzangolab/fastify-slonik"; @@ -433,17 +390,9 @@ export default UserSqlFactory; import { BaseService } from "@dzangolab/fastify-slonik"; import UserSqlFactory from "./sqlFactory"; -import { - User, - UserCreateInput, - UserUpdateInput, -} from "../../types"; - -class UserService extends BaseService< - User, - UsereCreateInput, - UserUpdateInput -> { +import { User, UserCreateInput, UserUpdateInput } from "../../types"; + +class UserService extends BaseService { get factory(): UserSqlFactory { return super.factory as UserSqlFactory; } @@ -457,12 +406,9 @@ export default UserService; ``` #### Extending functionality by overriding methods: + ```ts -class UserService extends BaseService< - User, - UsereCreateInput, - UserUpdateInput -> { +class UserService extends BaseService { async update(data: C): Promise { const user = await super.update(data); @@ -476,219 +422,168 @@ export default UserService; ``` ### Migration Guide (for upgrading from 0.80.1 or earlier) -* Ensure every entity has a associated SqlFactory (at minimum, to define the `TABLE` name). -* Remove generic types from SqlFactory definitions. -* Move all the database config inside SqlFactory (static properties like TABLE, LIMIT_DEFAULT, and SORT_KEY) -* Refactor service methods to be publicly overridable instead of instance properties. -* Update services to use the entity type directly, instead of relying on generics. -Refer to: +- Ensure every entity has a associated SqlFactory (at minimum, to define the `TABLE` name). +- Remove generic types from SqlFactory definitions. +- Move all the database config inside SqlFactory (static properties like TABLE, LIMIT_DEFAULT, and SORT_KEY) +- Refactor service methods to be publicly overridable instead of instance properties. +- Update services to use the entity type directly, instead of relying on generics. -* [InviationSqlFactory changes](https://github.com/dzangolab/fastify/pull/926/files#diff-47f88ff17d3c11a7866b0cd7bfef5d7666de929bfcd0baf78b3fa1d87fb9e9e5) +Refer to: -* [InvitationService changes](https://github.com/dzangolab/fastify/pull/926/files#diff-9783f01520622eb1f26b8425c84e8c361d7b1988432734ce1c794fd7e580b917) +- [InviationSqlFactory changes](https://github.com/dzangolab/fastify/pull/926/files#diff-47f88ff17d3c11a7866b0cd7bfef5d7666de929bfcd0baf78b3fa1d87fb9e9e5) +- [InvitationService changes](https://github.com/dzangolab/fastify/pull/926/files#diff-9783f01520622eb1f26b8425c84e8c361d7b1988432734ce1c794fd7e580b917) ## [0.80.1](https://github.com/dzangolab/fastify/compare/v0.80.0...v0.80.1) (2025-04-23) - - # [0.80.0](https://github.com/dzangolab/fastify/compare/v0.79.0...v0.80.0) (2025-04-04) - ### BREAKING CHANGES -* Requires Fastify >=5.2.1. See [V5 Migration Guide](https://fastify.dev/docs/latest/Guides/Migration-Guide-V5) for more details. -* Fastify v5 will only support Node.js v20+. -* @dzangolab/multi-tenant package is deprecated. +- Requires Fastify >=5.2.1. See [V5 Migration Guide](https://fastify.dev/docs/latest/Guides/Migration-Guide-V5) for more details. +- Fastify v5 will only support Node.js v20+. +- @dzangolab/multi-tenant package is deprecated. ### Fixes -* **deps:** update fastify to >=5.2.1 ([#915](https://github.com/dzangolab/fastify/issues/915)) ([38617b3](https://github.com/dzangolab/fastify/commit/b15e5aec71dc2fc3c068ca5c3d0e7dde6237d12d)) - -* **deprecate:** chore: mark multi-tenant package as deprecated ([#918](https://github.com/dzangolab/fastify/issues/918)) ([38617b3](https://github.com/dzangolab/fastify/commit/f2762a0509c9d69bb89c0fca31589a436d10c0b1)) +- **deps:** update fastify to >=5.2.1 ([#915](https://github.com/dzangolab/fastify/issues/915)) ([38617b3](https://github.com/dzangolab/fastify/commit/b15e5aec71dc2fc3c068ca5c3d0e7dde6237d12d)) +- **deprecate:** chore: mark multi-tenant package as deprecated ([#918](https://github.com/dzangolab/fastify/issues/918)) ([38617b3](https://github.com/dzangolab/fastify/commit/f2762a0509c9d69bb89c0fca31589a436d10c0b1)) # [0.79.0](https://github.com/dzangolab/fastify/compare/v0.78.0...v0.79.0) (2025-03-11) ### Bug Fixes -* **deps:** update dependency nodemailer to v6.10.0 ([#896](https://github.com/dzangolab/fastify/issues/896)) ([38617b3](https://github.com/dzangolab/fastify/commit/38617b3125bc93e3c7f3432a8032791e42840342)) -* remove unnecessary columns from the user migration ([#908](https://github.com/dzangolab/fastify/issues/908)) ([3a71752](https://github.com/dzangolab/fastify/commit/3a7175205c1d95b2ee5588c9efd6afb899e430d7)) - +- **deps:** update dependency nodemailer to v6.10.0 ([#896](https://github.com/dzangolab/fastify/issues/896)) ([38617b3](https://github.com/dzangolab/fastify/commit/38617b3125bc93e3c7f3432a8032791e42840342)) +- remove unnecessary columns from the user migration ([#908](https://github.com/dzangolab/fastify/issues/908)) ([3a71752](https://github.com/dzangolab/fastify/commit/3a7175205c1d95b2ee5588c9efd6afb899e430d7)) ### Features -* add users and invitations migrations ([#905](https://github.com/dzangolab/fastify/issues/905)) ([09d4423](https://github.com/dzangolab/fastify/commit/09d4423bac12422f6eb6cc62ccfb1f0b4f0ab913)) -* allow user to add additional roles ([#907](https://github.com/dzangolab/fastify/issues/907)) ([0a41dd7](https://github.com/dzangolab/fastify/commit/0a41dd74aef826cc4841995b59a6f34491d1464e)) - - +- add users and invitations migrations ([#905](https://github.com/dzangolab/fastify/issues/905)) ([09d4423](https://github.com/dzangolab/fastify/commit/09d4423bac12422f6eb6cc62ccfb1f0b4f0ab913)) +- allow user to add additional roles ([#907](https://github.com/dzangolab/fastify/issues/907)) ([0a41dd7](https://github.com/dzangolab/fastify/commit/0a41dd74aef826cc4841995b59a6f34491d1464e)) # [0.78.0](https://github.com/dzangolab/fastify/compare/v0.77.7...v0.78.0) (2025-03-07) - ### Features -* customizable email subject from config ([#902](https://github.com/dzangolab/fastify/issues/902)) ([3b7600a](https://github.com/dzangolab/fastify/commit/3b7600aeb3beed730c26665c608fcafe2c6753d2)) - - +- customizable email subject from config ([#902](https://github.com/dzangolab/fastify/issues/902)) ([3b7600a](https://github.com/dzangolab/fastify/commit/3b7600aeb3beed730c26665c608fcafe2c6753d2)) ## [0.77.7](https://github.com/dzangolab/fastify/compare/v0.77.6...v0.77.7) (2025-02-19) - ### Bug Fixes -* fix plugins being registered mixes async and callback styles ([#891](https://github.com/dzangolab/fastify/issues/891)) ([b0a1140](https://github.com/dzangolab/fastify/commit/b0a1140523c1df6cbce5148a3880904fa524dc56)) - - +- fix plugins being registered mixes async and callback styles ([#891](https://github.com/dzangolab/fastify/issues/891)) ([b0a1140](https://github.com/dzangolab/fastify/commit/b0a1140523c1df6cbce5148a3880904fa524dc56)) ## [0.77.6](https://github.com/dzangolab/fastify/compare/v0.77.5...v0.77.6) (2025-02-19) - ### Bug Fixes -* **deps:** update dependency @graphql-tools/merge to v9.0.19 ([#835](https://github.com/dzangolab/fastify/issues/835)) ([c9c25fd](https://github.com/dzangolab/fastify/commit/c9c25fdd188689e8e872d6ba040d764ad77fd74f)) -* **deps:** update dependency nodemailer-mjml to v1.4.12 ([#876](https://github.com/dzangolab/fastify/issues/876)) ([1f3bdd2](https://github.com/dzangolab/fastify/commit/1f3bdd2fa3bb3a83ed168cc2022e2c24fd146805)) - - +- **deps:** update dependency @graphql-tools/merge to v9.0.19 ([#835](https://github.com/dzangolab/fastify/issues/835)) ([c9c25fd](https://github.com/dzangolab/fastify/commit/c9c25fdd188689e8e872d6ba040d764ad77fd74f)) +- **deps:** update dependency nodemailer-mjml to v1.4.12 ([#876](https://github.com/dzangolab/fastify/issues/876)) ([1f3bdd2](https://github.com/dzangolab/fastify/commit/1f3bdd2fa3bb3a83ed168cc2022e2c24fd146805)) ## [0.77.5](https://github.com/dzangolab/fastify/compare/v0.77.4...v0.77.5) (2025-02-18) - - ## [0.77.4](https://github.com/dzangolab/fastify/compare/v0.77.3...v0.77.4) (2025-02-14) - - ## [0.77.3](https://github.com/dzangolab/fastify/compare/v0.77.2...v0.77.3) (2025-02-03) - - ## [0.77.2](https://github.com/dzangolab/fastify/compare/v0.77.1...v0.77.2) (2025-01-27) - ### Bug Fixes -* update verify email utility function ([3b9b77e](https://github.com/dzangolab/fastify/commit/3b9b77e5c9dd8e35b71c836584dcc7b0b2ff485c)) - - +- update verify email utility function ([3b9b77e](https://github.com/dzangolab/fastify/commit/3b9b77e5c9dd8e35b71c836584dcc7b0b2ff485c)) ## [0.77.1](https://github.com/dzangolab/fastify/compare/v0.77.0...v0.77.1) (2025-01-27) - ### Bug Fixes -* update verify email utility to support user context argument ([1391117](https://github.com/dzangolab/fastify/commit/1391117b6edb3ca6b56af18551fb1c4426ac723e)) - - +- update verify email utility to support user context argument ([1391117](https://github.com/dzangolab/fastify/commit/1391117b6edb3ca6b56af18551fb1c4426ac723e)) # [0.77.0](https://github.com/dzangolab/fastify/compare/v0.76.4...v0.77.0) (2025-01-03) - ### Features -* **user:** add change email mutation for graphql ([#856](https://github.com/dzangolab/fastify/issues/856)) ([fbfa956](https://github.com/dzangolab/fastify/commit/fbfa956ec872f2d8d9ceec8f3f5e51a2ec2e0a7f)) - - +- **user:** add change email mutation for graphql ([#856](https://github.com/dzangolab/fastify/issues/856)) ([fbfa956](https://github.com/dzangolab/fastify/commit/fbfa956ec872f2d8d9ceec8f3f5e51a2ec2e0a7f)) ## [0.76.4](https://github.com/dzangolab/fastify/compare/v0.76.3...v0.76.4) (2024-12-31) - ### Features -* **user:** include thirdParty information in user response ([#854](https://github.com/dzangolab/fastify/issues/854)) ([1039b92](https://github.com/dzangolab/fastify/commit/1039b9226f7c4c34782b8b7026bb207bf45c9046)) - - +- **user:** include thirdParty information in user response ([#854](https://github.com/dzangolab/fastify/issues/854)) ([1039b92](https://github.com/dzangolab/fastify/commit/1039b9226f7c4c34782b8b7026bb207bf45c9046)) ## [0.76.3](https://github.com/dzangolab/fastify/compare/v0.76.2...v0.76.3) (2024-12-25) -* **user:** fix email verification link for change email ([#852](https://github.com/dzangolab/fastify/issues/852)) ([1ac20c3](https://github.com/dzangolab/fastify/commit/1ac20c3927c23c6c489f8505162276180045f6e9)) +- **user:** fix email verification link for change email ([#852](https://github.com/dzangolab/fastify/issues/852)) ([1ac20c3](https://github.com/dzangolab/fastify/commit/1ac20c3927c23c6c489f8505162276180045f6e9)) ## [0.76.2](https://github.com/dzangolab/fastify/compare/v0.76.1...v0.76.2) (2024-12-24) - ### Features -* **slonik:** add find and findOne method in service class ([#850](https://github.com/dzangolab/fastify/issues/850)) ([337bdf3](https://github.com/dzangolab/fastify/commit/337bdf33f20453eb4ec00393c2e67703f0d6cd16)) +- **slonik:** add find and findOne method in service class ([#850](https://github.com/dzangolab/fastify/issues/850)) ([337bdf3](https://github.com/dzangolab/fastify/commit/337bdf33f20453eb4ec00393c2e67703f0d6cd16)) 1ac20c3927c23c6c489f8505162276180045f6e9 ## [0.76.1](https://github.com/dzangolab/fastify/compare/v0.76.0...v0.76.1) (2024-12-19) - ### Performance Improvements -* **user:** update email if new email is different than current ([#846](https://github.com/dzangolab/fastify/issues/846)) ([4a56e00](https://github.com/dzangolab/fastify/commit/4a56e005560f2a9b61ea003b6404e37bb2ee254e)) - - +- **user:** update email if new email is different than current ([#846](https://github.com/dzangolab/fastify/issues/846)) ([4a56e00](https://github.com/dzangolab/fastify/commit/4a56e005560f2a9b61ea003b6404e37bb2ee254e)) # [0.76.0](https://github.com/dzangolab/fastify/compare/v0.75.5...v0.76.0) (2024-12-18) - ### Features -* **user:** add rest endpoint to update email ([#841](https://github.com/dzangolab/fastify/issues/841)) ([17295e8](https://github.com/dzangolab/fastify/commit/17295e8ca77f81ea2f74bb846b0ec2cd1f4cae87)) -* **user:** add config to toggle email update feature ([#844](https://github.com/dzangolab/fastify/issues/844)) ([f828372](https://github.com/dzangolab/fastify/commit/f82837201f2a57087119751be524ee8354f169b0)) -* **user:** allow user to update email in case of unverified current email ([#843](https://github.com/dzangolab/fastify/issues/843)) ([79575d0](https://github.com/dzangolab/fastify/commit/79575d082f6dc647d8c40e988f9f3a92d6a61a02)) -* **user:** disallow update email if user with same email already exists ([#842](https://github.com/dzangolab/fastify/issues/842)) ([791fa30](https://github.com/dzangolab/fastify/commit/791fa30422cb144ea941614f5bf6651c6cb1acca)) - +- **user:** add rest endpoint to update email ([#841](https://github.com/dzangolab/fastify/issues/841)) ([17295e8](https://github.com/dzangolab/fastify/commit/17295e8ca77f81ea2f74bb846b0ec2cd1f4cae87)) +- **user:** add config to toggle email update feature ([#844](https://github.com/dzangolab/fastify/issues/844)) ([f828372](https://github.com/dzangolab/fastify/commit/f82837201f2a57087119751be524ee8354f169b0)) +- **user:** allow user to update email in case of unverified current email ([#843](https://github.com/dzangolab/fastify/issues/843)) ([79575d0](https://github.com/dzangolab/fastify/commit/79575d082f6dc647d8c40e988f9f3a92d6a61a02)) +- **user:** disallow update email if user with same email already exists ([#842](https://github.com/dzangolab/fastify/issues/842)) ([791fa30](https://github.com/dzangolab/fastify/commit/791fa30422cb144ea941614f5bf6651c6cb1acca)) ## [0.75.5](https://github.com/dzangolab/fastify/compare/v0.75.4...v0.75.5) (2024-12-04) - ### Bug Fixes -* **deps:** update aws-sdk-js-v3 monorepo to v3.701.0 ([#747](https://github.com/dzangolab/fastify/issues/747)) ([47a7c34](https://github.com/dzangolab/fastify/commit/47a7c34576464f3b3353cada5259c1ec0a9eca66)) -* **deps:** update dependency @graphql-tools/merge to v9.0.11 ([#826](https://github.com/dzangolab/fastify/issues/826)) ([020d7dd](https://github.com/dzangolab/fastify/commit/020d7ddaf8a06d18097d5701b5c76a79ea1c3894)) -* **deps:** update dependency firebase-admin to v12.7.0 ([#817](https://github.com/dzangolab/fastify/issues/817)) ([bc1db41](https://github.com/dzangolab/fastify/commit/bc1db418a6d2918eeb39816069baa05b48cc5011)) -* **deps:** update dependency graphql-upload-minimal to v1.6.1 ([#751](https://github.com/dzangolab/fastify/issues/751)) ([d074b80](https://github.com/dzangolab/fastify/commit/d074b80e0991b6c136f6d4a73907fd0c4ff72c05)) -* **deps:** update dependency nodemailer-mjml to v1.4.7 ([#796](https://github.com/dzangolab/fastify/issues/796)) ([17e6a6f](https://github.com/dzangolab/fastify/commit/17e6a6f87ae0af5293d4b26782fe1f1fa3e0ec42)) -* **deps:** update dependency pg to v8.13.1 ([#767](https://github.com/dzangolab/fastify/issues/767)) ([0241f5e](https://github.com/dzangolab/fastify/commit/0241f5ecedc1cbb9ca6914990e4e0e98f45b57e1)) -* error response for package endpoints ([#822](https://github.com/dzangolab/fastify/issues/822)) ([e520d8c](https://github.com/dzangolab/fastify/commit/e520d8c36c7dc57fe022b1b1aee29173630d6ee6)) - - +- **deps:** update aws-sdk-js-v3 monorepo to v3.701.0 ([#747](https://github.com/dzangolab/fastify/issues/747)) ([47a7c34](https://github.com/dzangolab/fastify/commit/47a7c34576464f3b3353cada5259c1ec0a9eca66)) +- **deps:** update dependency @graphql-tools/merge to v9.0.11 ([#826](https://github.com/dzangolab/fastify/issues/826)) ([020d7dd](https://github.com/dzangolab/fastify/commit/020d7ddaf8a06d18097d5701b5c76a79ea1c3894)) +- **deps:** update dependency firebase-admin to v12.7.0 ([#817](https://github.com/dzangolab/fastify/issues/817)) ([bc1db41](https://github.com/dzangolab/fastify/commit/bc1db418a6d2918eeb39816069baa05b48cc5011)) +- **deps:** update dependency graphql-upload-minimal to v1.6.1 ([#751](https://github.com/dzangolab/fastify/issues/751)) ([d074b80](https://github.com/dzangolab/fastify/commit/d074b80e0991b6c136f6d4a73907fd0c4ff72c05)) +- **deps:** update dependency nodemailer-mjml to v1.4.7 ([#796](https://github.com/dzangolab/fastify/issues/796)) ([17e6a6f](https://github.com/dzangolab/fastify/commit/17e6a6f87ae0af5293d4b26782fe1f1fa3e0ec42)) +- **deps:** update dependency pg to v8.13.1 ([#767](https://github.com/dzangolab/fastify/issues/767)) ([0241f5e](https://github.com/dzangolab/fastify/commit/0241f5ecedc1cbb9ca6914990e4e0e98f45b57e1)) +- error response for package endpoints ([#822](https://github.com/dzangolab/fastify/issues/822)) ([e520d8c](https://github.com/dzangolab/fastify/commit/e520d8c36c7dc57fe022b1b1aee29173630d6ee6)) ## [0.75.4](https://github.com/dzangolab/fastify/compare/v0.75.3...v0.75.4) (2024-11-25) - ### Bug Fixes -* **multi-tenant:** fix change password by account user ([#698](https://github.com/dzangolab/fastify/issues/698)) ([6fae887](https://github.com/dzangolab/fastify/commit/6fae8877f0f50214ef64d250f0010cfbc2819a38)) - - +- **multi-tenant:** fix change password by account user ([#698](https://github.com/dzangolab/fastify/issues/698)) ([6fae887](https://github.com/dzangolab/fastify/commit/6fae8877f0f50214ef64d250f0010cfbc2819a38)) ## [0.75.3](https://github.com/dzangolab/fastify/compare/v0.75.2...v0.75.3) (2024-11-20) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.16 ([#812](https://github.com/dzangolab/fastify/issues/812)) ([446f00c](https://github.com/dzangolab/fastify/commit/446f00ca13b82395ae33132bd648022ff4ac42c4)) -* **multi-tenant:** fix change schema query ([fb93d02](https://github.com/dzangolab/fastify/commit/fb93d02e815f9fc1054979622939e6a680e9aee7)) - - +- **deps:** update dependency nodemailer to v6.9.16 ([#812](https://github.com/dzangolab/fastify/issues/812)) ([446f00c](https://github.com/dzangolab/fastify/commit/446f00ca13b82395ae33132bd648022ff4ac42c4)) +- **multi-tenant:** fix change schema query ([fb93d02](https://github.com/dzangolab/fastify/commit/fb93d02e815f9fc1054979622939e6a680e9aee7)) ## [0.75.2](https://github.com/dzangolab/fastify/compare/v0.75.1...v0.75.2) (2024-11-07) ### Features -* **slonik:** support query logging ([#786](https://github.com/dzangolab/fastify/issues/805)) ([1be2670](https://github.com/dzangolab/fastify/commit/1be2670eb5a8d31d21ad51673388a91cbe29b5f2)) +- **slonik:** support query logging ([#786](https://github.com/dzangolab/fastify/issues/805)) ([1be2670](https://github.com/dzangolab/fastify/commit/1be2670eb5a8d31d21ad51673388a91cbe29b5f2)) ## [0.75.1](https://github.com/dzangolab/fastify/compare/v0.75.0...v0.75.1) (2024-11-06) - ### Bug Fixes -* **user:** skip email verification check for get me route ([#806](https://github.com/dzangolab/fastify/issues/806)) ([bde5d07](https://github.com/dzangolab/fastify/commit/bde5d07e66a0eab639acf72180f18ab2dcd1f5be)) - - +- **user:** skip email verification check for get me route ([#806](https://github.com/dzangolab/fastify/issues/806)) ([bde5d07](https://github.com/dzangolab/fastify/commit/bde5d07e66a0eab639acf72180f18ab2dcd1f5be)) # [0.75.0](https://github.com/dzangolab/fastify/compare/v0.74.1...v0.75.0) (2024-10-30) ### BREAKING CHANGES -* (slonik): Removes createMockPool, Dev should use database connection instead of mocking. -* (slonik): Removed config to disable slonik package migration .i.e. `migrations.package` is removed from SlonikOptions. -* (slonik): Removed migration to auto update updated_at column for tables that that updated_at column in all schema but you can still run this sql from application or directly in postgres +- (slonik): Removes createMockPool, Dev should use database connection instead of mocking. +- (slonik): Removed config to disable slonik package migration .i.e. `migrations.package` is removed from SlonikOptions. +- (slonik): Removed migration to auto update updated_at column for tables that that updated_at column in all schema but you can still run this sql from application or directly in postgres + ```sql /* Update updated_at column for a table. */ CREATE OR REPLACE FUNCTION update_updated_at_column() @@ -775,1720 +670,1184 @@ Refer to: ## [0.74.1](https://github.com/dzangolab/fastify/compare/v0.74.0...v0.74.1) (2024-10-23) - ### Bug Fixes -* **deps:** update dependency @graphql-tools/merge to v9.0.8 ([#795](https://github.com/dzangolab/fastify/issues/795)) ([16aaee2](https://github.com/dzangolab/fastify/commit/16aaee21028c43250064e9da582de7ac24c450d2)) -* **deps:** update turbo monorepo to v2.1.3 ([#763](https://github.com/dzangolab/fastify/issues/763)) ([ba1624b](https://github.com/dzangolab/fastify/commit/ba1624bd325688c08185a48bca929ce04f324221)) - +- **deps:** update dependency @graphql-tools/merge to v9.0.8 ([#795](https://github.com/dzangolab/fastify/issues/795)) ([16aaee2](https://github.com/dzangolab/fastify/commit/16aaee21028c43250064e9da582de7ac24c450d2)) +- **deps:** update turbo monorepo to v2.1.3 ([#763](https://github.com/dzangolab/fastify/issues/763)) ([ba1624b](https://github.com/dzangolab/fastify/commit/ba1624bd325688c08185a48bca929ce04f324221)) ### Features -* **firebase:** support options as argument by firebase plugin ([#791](https://github.com/dzangolab/fastify/issues/791)) ([653ab96](https://github.com/dzangolab/fastify/commit/653ab969be99d4d0d8bc71e8beac4eb06695645c)) -* **slonik:** support options as argument by slonik plugin ([#786](https://github.com/dzangolab/fastify/issues/786)) ([fb1097d](https://github.com/dzangolab/fastify/commit/fb1097d1ab0d9a68563da54a59475305c27990be)) - - +- **firebase:** support options as argument by firebase plugin ([#791](https://github.com/dzangolab/fastify/issues/791)) ([653ab96](https://github.com/dzangolab/fastify/commit/653ab969be99d4d0d8bc71e8beac4eb06695645c)) +- **slonik:** support options as argument by slonik plugin ([#786](https://github.com/dzangolab/fastify/issues/786)) ([fb1097d](https://github.com/dzangolab/fastify/commit/fb1097d1ab0d9a68563da54a59475305c27990be)) # [0.74.0](https://github.com/dzangolab/fastify/compare/v0.73.1...v0.74.0) (2024-10-04) - ### BREAKING CHANGES -* By default, the package automatically registers its routes. However, route registration can be disabled if needed. +- By default, the package automatically registers its routes. However, route registration can be disabled if needed. ### Features -* **graphql:** support options as argument by graphql plugin ([#779](https://github.com/dzangolab/fastify/issues/779)) ([b6faf81](https://github.com/dzangolab/fastify/commit/b6faf81c0f63b684f251b4e0dd25c43d707fb19b)) - +- **graphql:** support options as argument by graphql plugin ([#779](https://github.com/dzangolab/fastify/issues/779)) ([b6faf81](https://github.com/dzangolab/fastify/commit/b6faf81c0f63b684f251b4e0dd25c43d707fb19b)) ### Reverts -* Revert "pnpm: link-workspace-packages to default value (#766)" (#783) ([85688fe](https://github.com/dzangolab/fastify/commit/85688fed3eb33d4cf7cd6d6b04b5197f89cca19f)), closes [#766](https://github.com/dzangolab/fastify/issues/766) [#783](https://github.com/dzangolab/fastify/issues/783) - - +- Revert "pnpm: link-workspace-packages to default value (#766)" (#783) ([85688fe](https://github.com/dzangolab/fastify/commit/85688fed3eb33d4cf7cd6d6b04b5197f89cca19f)), closes [#766](https://github.com/dzangolab/fastify/issues/766) [#783](https://github.com/dzangolab/fastify/issues/783) ## [0.73.1](https://github.com/dzangolab/fastify/compare/v0.73.0...v0.73.1) (2024-09-25) - ### Features -* **user:** support custom prefix for Supertokens API routes ([#775](https://github.com/dzangolab/fastify/issues/775)) ([b286a20](https://github.com/dzangolab/fastify/commit/b286a200ba558abafacb50c26946f6e91d3f228d)) - - +- **user:** support custom prefix for Supertokens API routes ([#775](https://github.com/dzangolab/fastify/issues/775)) ([b286a20](https://github.com/dzangolab/fastify/commit/b286a200ba558abafacb50c26946f6e91d3f228d)) # [0.73.0](https://github.com/dzangolab/fastify/compare/v0.72.1...v0.73.0) (2024-09-23) - ### Features -* **mailer:** add support for passing options as arguments to mailer plugin ([#772](https://github.com/dzangolab/fastify/issues/772)) ([3211ef0](https://github.com/dzangolab/fastify/commit/3211ef04aba68ba48093adca9e7b13886868cb72)) - - +- **mailer:** add support for passing options as arguments to mailer plugin ([#772](https://github.com/dzangolab/fastify/issues/772)) ([3211ef0](https://github.com/dzangolab/fastify/commit/3211ef04aba68ba48093adca9e7b13886868cb72)) ## [0.72.1](https://github.com/dzangolab/fastify/compare/v0.72.0...v0.72.1) (2024-09-11) - - # [0.72.0](https://github.com/dzangolab/fastify/compare/v0.71.3...v0.72.0) (2024-09-11) ### Features -* **slonik:** add support for custom sql factory class ([#742](https://github.com/dzangolab/fastify/issues/742)) ([4d63632](https://github.com/dzangolab/fastify/commit/4d63632b83916d3ffbd5616499b64eb2d43151ca)) - - +- **slonik:** add support for custom sql factory class ([#742](https://github.com/dzangolab/fastify/issues/742)) ([4d63632](https://github.com/dzangolab/fastify/commit/4d63632b83916d3ffbd5616499b64eb2d43151ca)) ## [0.71.3](https://github.com/dzangolab/fastify/compare/v0.71.2...v0.71.3) (2024-08-28) - ### Bug Fixes -* supress ts error not relevent to the current package ([a2a63b6](https://github.com/dzangolab/fastify/commit/a2a63b6a5c4124da2ea788425df78e4e7590cb0a)) - - +- supress ts error not relevent to the current package ([a2a63b6](https://github.com/dzangolab/fastify/commit/a2a63b6a5c4124da2ea788425df78e4e7590cb0a)) ## [0.71.2](https://github.com/dzangolab/fastify/compare/v0.71.1...v0.71.2) (2024-08-19) - - ## [0.71.1](https://github.com/dzangolab/fastify/compare/v0.71.0...v0.71.1) (2024-08-14) - ### Bug Fixes -* support removing graphql related packages when not used ([#719](https://github.com/dzangolab/fastify/issues/719)) ([05ffba1](https://github.com/dzangolab/fastify/commit/05ffba1db895faeef7eb21b4ae06c1ea307a2cae)) - - +- support removing graphql related packages when not used ([#719](https://github.com/dzangolab/fastify/issues/719)) ([05ffba1](https://github.com/dzangolab/fastify/commit/05ffba1db895faeef7eb21b4ae06c1ea307a2cae)) # [0.71.0](https://github.com/dzangolab/fastify/compare/v0.70.0...v0.71.0) (2024-08-02) - # [0.70.0](https://github.com/dzangolab/fastify/compare/v0.69.0...v0.70.0) (2024-08-01) - ### Bug Fixes -* **deps:** update dependency nodemailer-mjml to v1.3.6 ([#692](https://github.com/dzangolab/fastify/issues/692)) ([3afea41](https://github.com/dzangolab/fastify/commit/3afea419139efcad8f7aaf61d7934a859a425583)) -* **deps:** update turbo monorepo to v2.0.6 ([#693](https://github.com/dzangolab/fastify/issues/693)) ([4559462](https://github.com/dzangolab/fastify/commit/4559462d77cb1d02216a90cb9b6c4f4656fd9c80)) - +- **deps:** update dependency nodemailer-mjml to v1.3.6 ([#692](https://github.com/dzangolab/fastify/issues/692)) ([3afea41](https://github.com/dzangolab/fastify/commit/3afea419139efcad8f7aaf61d7934a859a425583)) +- **deps:** update turbo monorepo to v2.0.6 ([#693](https://github.com/dzangolab/fastify/issues/693)) ([4559462](https://github.com/dzangolab/fastify/commit/4559462d77cb1d02216a90cb9b6c4f4656fd9c80)) ### Features -* **graphql:** add graphql package ([#708](https://github.com/dzangolab/fastify/issues/708)) ([12e916c](https://github.com/dzangolab/fastify/commit/12e916c27149bc6bfe096a422a6d7f71ae576639)) - - +- **graphql:** add graphql package ([#708](https://github.com/dzangolab/fastify/issues/708)) ([12e916c](https://github.com/dzangolab/fastify/commit/12e916c27149bc6bfe096a422a6d7f71ae576639)) # [0.69.0](https://github.com/dzangolab/fastify/compare/v0.68.3...v0.69.0) (2024-06-24) - ### Bug Fixes -* **user:** fix createNewSession without request response ([#685](https://github.com/dzangolab/fastify/issues/685)) ([bb04ef3](https://github.com/dzangolab/fastify/commit/bb04ef3c58f00160593ab732e2e3df38002e9517)) - +- **user:** fix createNewSession without request response ([#685](https://github.com/dzangolab/fastify/issues/685)) ([bb04ef3](https://github.com/dzangolab/fastify/commit/bb04ef3c58f00160593ab732e2e3df38002e9517)) ### Features -* **user:** add support for grace period for profile validation ([#684](https://github.com/dzangolab/fastify/issues/684)) ([ab25ad2](https://github.com/dzangolab/fastify/commit/ab25ad2167f8e885f10afbd397f739d2f07c1bd8)) - - +- **user:** add support for grace period for profile validation ([#684](https://github.com/dzangolab/fastify/issues/684)) ([ab25ad2](https://github.com/dzangolab/fastify/commit/ab25ad2167f8e885f10afbd397f739d2f07c1bd8)) ## [0.68.3](https://github.com/dzangolab/fastify/compare/v0.68.2...v0.68.3) (2024-06-12) - - ## [0.68.2](https://github.com/dzangolab/fastify/compare/v0.68.1...v0.68.2) (2024-06-07) - - ## [0.68.1](https://github.com/dzangolab/fastify/compare/v0.68.0...v0.68.1) (2024-06-05) - ### Bug Fixes -* **user:** update session after user update ([#675](https://github.com/dzangolab/fastify/issues/675)) ([22d8d2f](https://github.com/dzangolab/fastify/commit/22d8d2f7a8c52374f566567bd8063a032e61d8fd)) - - +- **user:** update session after user update ([#675](https://github.com/dzangolab/fastify/issues/675)) ([22d8d2f](https://github.com/dzangolab/fastify/commit/22d8d2f7a8c52374f566567bd8063a032e61d8fd)) # [0.68.0](https://github.com/dzangolab/fastify/compare/v0.67.2...v0.68.0) (2024-06-05) - ### Features -* **user:** add endpoint for delete invitation ([#673](https://github.com/dzangolab/fastify/issues/673)) ([d9860d6](https://github.com/dzangolab/fastify/commit/d9860d68f69b06fe396cdafc8f19ad05bf4a51e6)) -* **user:** add user object to the fastify request ([#672](https://github.com/dzangolab/fastify/issues/672)) ([e9f141f](https://github.com/dzangolab/fastify/commit/e9f141f36422024e5fbd265f6eaa13110b40918e)) - - +- **user:** add endpoint for delete invitation ([#673](https://github.com/dzangolab/fastify/issues/673)) ([d9860d6](https://github.com/dzangolab/fastify/commit/d9860d68f69b06fe396cdafc8f19ad05bf4a51e6)) +- **user:** add user object to the fastify request ([#672](https://github.com/dzangolab/fastify/issues/672)) ([e9f141f](https://github.com/dzangolab/fastify/commit/e9f141f36422024e5fbd265f6eaa13110b40918e)) ## [0.67.2](https://github.com/dzangolab/fastify/compare/v0.67.1...v0.67.2) (2024-05-30) - ### Bug Fixes -* **user:** update profile validation claim in sesson for me ([#670](https://github.com/dzangolab/fastify/issues/670)) ([422ea43](https://github.com/dzangolab/fastify/commit/422ea437de8cd7eef6b6b0c7d1a35e553ca17408)) - - +- **user:** update profile validation claim in sesson for me ([#670](https://github.com/dzangolab/fastify/issues/670)) ([422ea43](https://github.com/dzangolab/fastify/commit/422ea437de8cd7eef6b6b0c7d1a35e553ca17408)) ## [0.67.1](https://github.com/dzangolab/fastify/compare/v0.67.0...v0.67.1) (2024-05-28) - ### Bug Fixes -* **user:** support multiple key of user in profile validation fields ([#668](https://github.com/dzangolab/fastify/issues/668)) ([80cdad9](https://github.com/dzangolab/fastify/commit/80cdad98da9d6b48984fb26d2b9fe81b1e6ed959)) - - +- **user:** support multiple key of user in profile validation fields ([#668](https://github.com/dzangolab/fastify/issues/668)) ([80cdad9](https://github.com/dzangolab/fastify/commit/80cdad98da9d6b48984fb26d2b9fe81b1e6ed959)) # [0.67.0](https://github.com/dzangolab/fastify/compare/v0.66.0...v0.67.0) (2024-05-28) - ### Features -* **user:** Add profile validation feature ([#664](https://github.com/dzangolab/fastify/issues/664)) ([db229da](https://github.com/dzangolab/fastify/commit/db229da2f53444649e0b5aa3ab8a0e6a65b9d6eb)) - - +- **user:** Add profile validation feature ([#664](https://github.com/dzangolab/fastify/issues/664)) ([db229da](https://github.com/dzangolab/fastify/commit/db229da2f53444649e0b5aa3ab8a0e6a65b9d6eb)) # [0.66.0](https://github.com/dzangolab/fastify/compare/v0.65.5...v0.66.0) (2024-05-17) - ### Features -* **user:** support appId as URI parameter for reset password request ([#660](https://github.com/dzangolab/fastify/issues/660)) ([fffff73](https://github.com/dzangolab/fastify/commit/fffff73d1890a1e3f93bb5e7e569b4e674a0e191)) - - +- **user:** support appId as URI parameter for reset password request ([#660](https://github.com/dzangolab/fastify/issues/660)) ([fffff73](https://github.com/dzangolab/fastify/commit/fffff73d1890a1e3f93bb5e7e569b4e674a0e191)) ## [0.65.5](https://github.com/dzangolab/fastify/compare/v0.65.4...v0.65.5) (2024-05-16) - - ## [0.65.4](https://github.com/dzangolab/fastify/compare/v0.65.3...v0.65.4) (2024-05-15) - - ## [0.65.3](https://github.com/dzangolab/fastify/compare/v0.65.2...v0.65.3) (2024-05-10) - - ## [0.65.2](https://github.com/dzangolab/fastify/compare/v0.65.1...v0.65.2) (2024-05-03) - ### Features -* **mailer:** add mail recipients ([#648](https://github.com/dzangolab/fastify/issues/648)) ([d4bcece](https://github.com/dzangolab/fastify/commit/d4bcecec0904d3a0b94c15a17266360588c5d5b3)) - - +- **mailer:** add mail recipients ([#648](https://github.com/dzangolab/fastify/issues/648)) ([d4bcece](https://github.com/dzangolab/fastify/commit/d4bcecec0904d3a0b94c15a17266360588c5d5b3)) ## [0.65.1](https://github.com/dzangolab/fastify/compare/v0.65.0...v0.65.1) (2024-04-30) - ### Bug Fixes -* **user:** fix create invitation for existing invitation which is invalidated ([#649](https://github.com/dzangolab/fastify/issues/649)) ([e668b85](https://github.com/dzangolab/fastify/commit/e668b852cc37935ce5482475052952ed3f0bb835)) - - +- **user:** fix create invitation for existing invitation which is invalidated ([#649](https://github.com/dzangolab/fastify/issues/649)) ([e668b85](https://github.com/dzangolab/fastify/commit/e668b852cc37935ce5482475052952ed3f0bb835)) # [0.65.0](https://github.com/dzangolab/fastify/compare/v0.64.2...v0.65.0) (2024-04-25) - ### Features -* **user:** enforce session check in database ([#646](https://github.com/dzangolab/fastify/issues/646)) ([bc22242](https://github.com/dzangolab/fastify/commit/bc22242b8e3e4f5f15b5b7591b16f57354ee85d0)) - - +- **user:** enforce session check in database ([#646](https://github.com/dzangolab/fastify/issues/646)) ([bc22242](https://github.com/dzangolab/fastify/commit/bc22242b8e3e4f5f15b5b7591b16f57354ee85d0)) ## [0.64.2](https://github.com/dzangolab/fastify/compare/v0.64.1...v0.64.2) (2024-04-02) - ### Bug Fixes -* **multi-tenant:** fix tenant emailPassword sign in ([#635](https://github.com/dzangolab/fastify/issues/635)) ([76aa036](https://github.com/dzangolab/fastify/commit/76aa0366b9771f4bc5140c23c23b3184c3c7184d)) - - +- **multi-tenant:** fix tenant emailPassword sign in ([#635](https://github.com/dzangolab/fastify/issues/635)) ([76aa036](https://github.com/dzangolab/fastify/commit/76aa0366b9771f4bc5140c23c23b3184c3c7184d)) ## [0.64.1](https://github.com/dzangolab/fastify/compare/v0.64.0...v0.64.1) (2024-03-27) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.9 [security] ([#607](https://github.com/dzangolab/fastify/issues/607)) ([f37d349](https://github.com/dzangolab/fastify/commit/f37d3492cbff5b57f55dbfa5cd88f293b957f225)) -* **multi-tenant:** add 404 status code in response if such tenant does not exits ([#628](https://github.com/dzangolab/fastify/issues/628)) ([10bc5b2](https://github.com/dzangolab/fastify/commit/10bc5b263e80f2bc328c33a76f3067d36c3560d5)) -* **user:** block create role if it already exists ([#629](https://github.com/dzangolab/fastify/issues/629)) ([65cfb29](https://github.com/dzangolab/fastify/commit/65cfb29af0d448a715c53a02fcf903a570c75902)) - - +- **deps:** update dependency nodemailer to v6.9.9 [security] ([#607](https://github.com/dzangolab/fastify/issues/607)) ([f37d349](https://github.com/dzangolab/fastify/commit/f37d3492cbff5b57f55dbfa5cd88f293b957f225)) +- **multi-tenant:** add 404 status code in response if such tenant does not exits ([#628](https://github.com/dzangolab/fastify/issues/628)) ([10bc5b2](https://github.com/dzangolab/fastify/commit/10bc5b263e80f2bc328c33a76f3067d36c3560d5)) +- **user:** block create role if it already exists ([#629](https://github.com/dzangolab/fastify/issues/629)) ([65cfb29](https://github.com/dzangolab/fastify/commit/65cfb29af0d448a715c53a02fcf903a570c75902)) # [0.64.0](https://github.com/dzangolab/fastify/compare/v0.63.0...v0.64.0) (2024-03-23) - ### Features -* **user:** add permissions while creating role ([#622](https://github.com/dzangolab/fastify/issues/622)) ([5023a51](https://github.com/dzangolab/fastify/commit/5023a5151a210b4a6d71b83be53e08c16e2a4cd3)) - - +- **user:** add permissions while creating role ([#622](https://github.com/dzangolab/fastify/issues/622)) ([5023a51](https://github.com/dzangolab/fastify/commit/5023a5151a210b4a6d71b83be53e08c16e2a4cd3)) # [0.63.0](https://github.com/dzangolab/fastify/compare/v0.62.4...v0.63.0) (2024-03-22) - ### Features -* **config:** add multi-stream support for logger config ([#616](https://github.com/dzangolab/fastify/issues/616)) ([d9ebb2e](https://github.com/dzangolab/fastify/commit/d9ebb2efc4a5785d5e2b6519b4decb1014f6f1af)) - - +- **config:** add multi-stream support for logger config ([#616](https://github.com/dzangolab/fastify/issues/616)) ([d9ebb2e](https://github.com/dzangolab/fastify/commit/d9ebb2efc4a5785d5e2b6519b4decb1014f6f1af)) ## [0.62.4](https://github.com/dzangolab/fastify/compare/v0.62.3...v0.62.4) (2024-03-21) - ### Bug Fixes -* update db filter input type ([#624](https://github.com/dzangolab/fastify/issues/624)) ([7a24d4c](https://github.com/dzangolab/fastify/commit/7a24d4c1a1f308fc9d33d0f387d3866b8dac1e05)) - - +- update db filter input type ([#624](https://github.com/dzangolab/fastify/issues/624)) ([7a24d4c](https://github.com/dzangolab/fastify/commit/7a24d4c1a1f308fc9d33d0f387d3866b8dac1e05)) ## [0.62.3](https://github.com/dzangolab/fastify/compare/v0.62.2...v0.62.3) (2024-03-20) - - ## [0.62.2](https://github.com/dzangolab/fastify/compare/v0.62.1...v0.62.2) (2024-03-07) - ### Features -* **multi-tenant:** check reserved slugs and domains before create tenant ([#606](https://github.com/dzangolab/fastify/issues/606)) ([79db810](https://github.com/dzangolab/fastify/commit/79db810285c59d444e59b598714381896d815f30)) +- **multi-tenant:** check reserved slugs and domains before create tenant ([#606](https://github.com/dzangolab/fastify/issues/606)) ([79db810](https://github.com/dzangolab/fastify/commit/79db810285c59d444e59b598714381896d815f30)) +## [0.62.1](https://github.com/dzangolab/fastify/compare/v0.62.0...v0.62.1) (2024-02-19) +### Features -## [0.62.1](https://github.com/dzangolab/fastify/compare/v0.62.0...v0.62.1) (2024-02-19) +- return host in tenant all request ([#614](https://github.com/dzangolab/fastify/issues/614)) ([058308a](https://github.com/dzangolab/fastify/commit/058308a201615ee4e69a0a8a6f5b351103ae0aee)) +- **user:** make invitations and user service configurable ([#511](https://github.com/dzangolab/fastify/issues/511)) ([3659f26](https://github.com/dzangolab/fastify/commit/3659f26c73007b8c99f391d0b95ddc5e2f9e2ae7)) +# [0.62.0](https://github.com/dzangolab/fastify/compare/v0.61.1...v0.62.0) (2024-02-12) ### Features -* return host in tenant all request ([#614](https://github.com/dzangolab/fastify/issues/614)) ([058308a](https://github.com/dzangolab/fastify/commit/058308a201615ee4e69a0a8a6f5b351103ae0aee)) -* **user:** make invitations and user service configurable ([#511](https://github.com/dzangolab/fastify/issues/511)) ([3659f26](https://github.com/dzangolab/fastify/commit/3659f26c73007b8c99f391d0b95ddc5e2f9e2ae7)) +- **user:** add support for custom third party provider ([#608](https://github.com/dzangolab/fastify/issues/608)) ([1a4ae53](https://github.com/dzangolab/fastify/commit/1a4ae53a8d3be37a56b0179a773e5fa77d72306d)) +## [0.61.1](https://github.com/dzangolab/fastify/compare/v0.61.0...v0.61.1) (2024-02-12) +### Bug Fixes -# [0.62.0](https://github.com/dzangolab/fastify/compare/v0.61.1...v0.62.0) (2024-02-12) +- remove role check in user enable and disable graphql resolver ([#611](https://github.com/dzangolab/fastify/issues/611)) ([2d84f4b](https://github.com/dzangolab/fastify/commit/2d84f4b13b425b34d91683f7c63d59bfcd38b726)) +# [0.61.0](https://github.com/dzangolab/fastify/compare/v0.60.0...v0.61.0) (2024-01-30) ### Features -* **user:** add support for custom third party provider ([#608](https://github.com/dzangolab/fastify/issues/608)) ([1a4ae53](https://github.com/dzangolab/fastify/commit/1a4ae53a8d3be37a56b0179a773e5fa77d72306d)) +- **multi-tenant:** add endpoint to get all tenants ([#604](https://github.com/dzangolab/fastify/issues/604)) ([70ed7bd](https://github.com/dzangolab/fastify/commit/70ed7bd3dec65f88b83332a1f7b331364beecdae)) +- **user:** auto verify first admin user email when email verification is enabled ([#603](https://github.com/dzangolab/fastify/issues/603)) ([e9ccf84](https://github.com/dzangolab/fastify/commit/e9ccf844fb06940f67da41ac87f9712aa428e941)) +# [0.60.0](https://github.com/dzangolab/fastify/compare/v0.59.0...v0.60.0) (2024-01-26) +### Features -## [0.61.1](https://github.com/dzangolab/fastify/compare/v0.61.0...v0.61.1) (2024-02-12) +- **user:** make accept invitation link path configurable ([#601](https://github.com/dzangolab/fastify/issues/601)) ([5d5aa1f](https://github.com/dzangolab/fastify/commit/5d5aa1fbd94aabf2d969a463cda4f750c3753d18)) +# [0.59.0](https://github.com/dzangolab/fastify/compare/v0.58.0...v0.59.0) (2024-01-25) ### Bug Fixes -* remove role check in user enable and disable graphql resolver ([#611](https://github.com/dzangolab/fastify/issues/611)) ([2d84f4b](https://github.com/dzangolab/fastify/commit/2d84f4b13b425b34d91683f7c63d59bfcd38b726)) - - - -# [0.61.0](https://github.com/dzangolab/fastify/compare/v0.60.0...v0.61.0) (2024-01-30) - - -### Features - -* **multi-tenant:** add endpoint to get all tenants ([#604](https://github.com/dzangolab/fastify/issues/604)) ([70ed7bd](https://github.com/dzangolab/fastify/commit/70ed7bd3dec65f88b83332a1f7b331364beecdae)) -* **user:** auto verify first admin user email when email verification is enabled ([#603](https://github.com/dzangolab/fastify/issues/603)) ([e9ccf84](https://github.com/dzangolab/fastify/commit/e9ccf844fb06940f67da41ac87f9712aa428e941)) - - - -# [0.60.0](https://github.com/dzangolab/fastify/compare/v0.59.0...v0.60.0) (2024-01-26) - - -### Features - -* **user:** make accept invitation link path configurable ([#601](https://github.com/dzangolab/fastify/issues/601)) ([5d5aa1f](https://github.com/dzangolab/fastify/commit/5d5aa1fbd94aabf2d969a463cda4f750c3753d18)) - - - -# [0.59.0](https://github.com/dzangolab/fastify/compare/v0.58.0...v0.59.0) (2024-01-25) - - -### Bug Fixes - -* **deps:** update dependency zod to v3.22.4 ([#591](https://github.com/dzangolab/fastify/issues/591)) ([b0b6b61](https://github.com/dzangolab/fastify/commit/b0b6b619e0c0bb0ac292d31752ad93c1f9e1fc0b)) -* **user:** fix link in email verification for first admin sign up ([#598](https://github.com/dzangolab/fastify/issues/598)) ([f991df5](https://github.com/dzangolab/fastify/commit/f991df5f076d7526501f83e27c0f04a0446d2b51)) - - -### Features - -* **multi-tenant:** add tenant owner role on sign up from www app ([#586](https://github.com/dzangolab/fastify/issues/586)) ([49c341d](https://github.com/dzangolab/fastify/commit/49c341d0d474744b93d0581a5f6d19bfbbff940b)) +- **deps:** update dependency zod to v3.22.4 ([#591](https://github.com/dzangolab/fastify/issues/591)) ([b0b6b61](https://github.com/dzangolab/fastify/commit/b0b6b619e0c0bb0ac292d31752ad93c1f9e1fc0b)) +- **user:** fix link in email verification for first admin sign up ([#598](https://github.com/dzangolab/fastify/issues/598)) ([f991df5](https://github.com/dzangolab/fastify/commit/f991df5f076d7526501f83e27c0f04a0446d2b51)) +### Features +- **multi-tenant:** add tenant owner role on sign up from www app ([#586](https://github.com/dzangolab/fastify/issues/586)) ([49c341d](https://github.com/dzangolab/fastify/commit/49c341d0d474744b93d0581a5f6d19bfbbff940b)) # [0.58.0](https://github.com/dzangolab/fastify/compare/v0.57.1...v0.58.0) (2024-01-16) - ### Bug Fixes -* **deps:** update dependency @types/busboy to v1.5.3 ([#559](https://github.com/dzangolab/fastify/issues/559)) ([da0cf54](https://github.com/dzangolab/fastify/commit/da0cf5454c5c3a18ebbbbadfe0f525573daa4ffa)) -* **deps:** update dependency nodemailer to v6.9.8 ([#589](https://github.com/dzangolab/fastify/issues/589)) ([0dc1a7d](https://github.com/dzangolab/fastify/commit/0dc1a7d8a1e028a816ed1cbbecb08ad1f462f585)) -* **deps:** update dependency uuid to v9.0.1 ([#590](https://github.com/dzangolab/fastify/issues/590)) ([3537316](https://github.com/dzangolab/fastify/commit/35373164f848a407ccd9241b976e1085c268bf02)) - +- **deps:** update dependency @types/busboy to v1.5.3 ([#559](https://github.com/dzangolab/fastify/issues/559)) ([da0cf54](https://github.com/dzangolab/fastify/commit/da0cf5454c5c3a18ebbbbadfe0f525573daa4ffa)) +- **deps:** update dependency nodemailer to v6.9.8 ([#589](https://github.com/dzangolab/fastify/issues/589)) ([0dc1a7d](https://github.com/dzangolab/fastify/commit/0dc1a7d8a1e028a816ed1cbbecb08ad1f462f585)) +- **deps:** update dependency uuid to v9.0.1 ([#590](https://github.com/dzangolab/fastify/issues/590)) ([3537316](https://github.com/dzangolab/fastify/commit/35373164f848a407ccd9241b976e1085c268bf02)) ### Features -* **multi-tenant:** add owner information on creating tenant ([3ca2756](https://github.com/dzangolab/fastify/commit/3ca27560e820bd7953e579ab9195962cb43f630e)) -* **multi-tenant:** As A tenant owner, I can only get tenants or a tenant created by me. ([#588](https://github.com/dzangolab/fastify/issues/588)) ([6c7bebb](https://github.com/dzangolab/fastify/commit/6c7bebbfbc4442c33506900468e46ea0527d6819)) - - +- **multi-tenant:** add owner information on creating tenant ([3ca2756](https://github.com/dzangolab/fastify/commit/3ca27560e820bd7953e579ab9195962cb43f630e)) +- **multi-tenant:** As A tenant owner, I can only get tenants or a tenant created by me. ([#588](https://github.com/dzangolab/fastify/issues/588)) ([6c7bebb](https://github.com/dzangolab/fastify/commit/6c7bebbfbc4442c33506900468e46ea0527d6819)) ## [0.57.1](https://github.com/dzangolab/fastify/compare/v0.57.0...v0.57.1) (2024-01-08) - - # [0.57.0](https://github.com/dzangolab/fastify/compare/v0.56.0...v0.57.0) (2024-01-04) - ### Features -* **user:** add role based access control (hasPermission middleware and directive to protect routes) ([#564](https://github.com/dzangolab/fastify/issues/564)) ([eca8909](https://github.com/dzangolab/fastify/commit/eca8909c8f5d23182531077ed8a9ee2fd5b8c5b6)) -* **multi-tenant:** add tenant controller and resolver ([#574](https://github.com/dzangolab/fastify/issues/574)) ([95bb1f9](https://github.com/dzangolab/fastify/commit/95bb1f96ac1b4a3218047a6aa219eb00dfe67d89)) - - +- **user:** add role based access control (hasPermission middleware and directive to protect routes) ([#564](https://github.com/dzangolab/fastify/issues/564)) ([eca8909](https://github.com/dzangolab/fastify/commit/eca8909c8f5d23182531077ed8a9ee2fd5b8c5b6)) +- **multi-tenant:** add tenant controller and resolver ([#574](https://github.com/dzangolab/fastify/issues/574)) ([95bb1f9](https://github.com/dzangolab/fastify/commit/95bb1f96ac1b4a3218047a6aa219eb00dfe67d89)) # [0.56.0](https://github.com/dzangolab/fastify/compare/v0.55.2...v0.56.0) (2023-12-25) - ### Features -* add payload support in send notification route ([#581](https://github.com/dzangolab/fastify/issues/581)) ([68c3f9a](https://github.com/dzangolab/fastify/commit/68c3f9a3421083188096034b4cdf7af28f418bd6)) -* **user:** fix create invitation issue when default role is not USER ([#565](https://github.com/dzangolab/fastify/issues/565)) ([7260f11](https://github.com/dzangolab/fastify/commit/7260f11c28164044094184f96f386a5259449bc5)) - - +- add payload support in send notification route ([#581](https://github.com/dzangolab/fastify/issues/581)) ([68c3f9a](https://github.com/dzangolab/fastify/commit/68c3f9a3421083188096034b4cdf7af28f418bd6)) +- **user:** fix create invitation issue when default role is not USER ([#565](https://github.com/dzangolab/fastify/issues/565)) ([7260f11](https://github.com/dzangolab/fastify/commit/7260f11c28164044094184f96f386a5259449bc5)) ## [0.55.2](https://github.com/dzangolab/fastify/compare/v0.55.1...v0.55.2) (2023-12-20) - - ## [0.55.1](https://github.com/dzangolab/fastify/compare/v0.55.0...v0.55.1) (2023-12-20) - - # [0.55.0](https://github.com/dzangolab/fastify/compare/v0.54.0...v0.55.0) (2023-12-19) - ### Features -* add remove device route and multi device notification support ([#575](https://github.com/dzangolab/fastify/issues/575)) ([cadb1ca](https://github.com/dzangolab/fastify/commit/cadb1ca389be99f6106f805b87b787bb0b9077bf)) -* **fastify-firebase:** check if app is already initialized before initializing app ([#571](https://github.com/dzangolab/fastify/issues/571)) ([d8ffaad](https://github.com/dzangolab/fastify/commit/d8ffaadc24044946ae14eb55c768a858f19ad3ed)) - - +- add remove device route and multi device notification support ([#575](https://github.com/dzangolab/fastify/issues/575)) ([cadb1ca](https://github.com/dzangolab/fastify/commit/cadb1ca389be99f6106f805b87b787bb0b9077bf)) +- **fastify-firebase:** check if app is already initialized before initializing app ([#571](https://github.com/dzangolab/fastify/issues/571)) ([d8ffaad](https://github.com/dzangolab/fastify/commit/d8ffaadc24044946ae14eb55c768a858f19ad3ed)) # [0.54.0](https://github.com/dzangolab/fastify/compare/v0.53.4...v0.54.0) (2023-12-15) - ### Features -* add fastify-firebase package for firebase admin utilities ([#566](https://github.com/dzangolab/fastify/issues/566)) ([8131906](https://github.com/dzangolab/fastify/commit/8131906083b575606438aa1ff8ea229d62a4e190)) - - +- add fastify-firebase package for firebase admin utilities ([#566](https://github.com/dzangolab/fastify/issues/566)) ([8131906](https://github.com/dzangolab/fastify/commit/8131906083b575606438aa1ff8ea229d62a4e190)) ## [0.53.4](https://github.com/dzangolab/fastify/compare/v0.53.3...v0.53.4) (2023-12-12) - ### Bug Fixes -* **multi-tenant:** send email verification to correct email address on signup ([#568](https://github.com/dzangolab/fastify/issues/568)) ([953a6aa](https://github.com/dzangolab/fastify/commit/953a6aace116beb2672dc76ba93ab68cd30b8851)) - - +- **multi-tenant:** send email verification to correct email address on signup ([#568](https://github.com/dzangolab/fastify/issues/568)) ([953a6aa](https://github.com/dzangolab/fastify/commit/953a6aace116beb2672dc76ba93ab68cd30b8851)) ## [0.53.3](https://github.com/dzangolab/fastify/compare/v0.53.2...v0.53.3) (2023-12-08) - ### Bug Fixes -* **multi-tenant:** session valid on tenant app where user is authenticated. ([a6b2286](https://github.com/dzangolab/fastify/commit/a6b2286af846f68c596c022d012e97024bb19739)) - - +- **multi-tenant:** session valid on tenant app where user is authenticated. ([a6b2286](https://github.com/dzangolab/fastify/commit/a6b2286af846f68c596c022d012e97024bb19739)) ## [0.53.2](https://github.com/dzangolab/fastify/compare/v0.53.1...v0.53.2) (2023-11-27) - ### Bug Fixes -* close pg client after migration ([10e7384](https://github.com/dzangolab/fastify/commit/10e7384d282084f481c19b9ce3a68d8842f264fd)) - - +- close pg client after migration ([10e7384](https://github.com/dzangolab/fastify/commit/10e7384d282084f481c19b9ce3a68d8842f264fd)) ## [0.53.1](https://github.com/dzangolab/fastify/compare/v0.53.0...v0.53.1) (2023-11-24) - ### Bug Fixes -* **slonik:** support ssl for database connection ([#560](https://github.com/dzangolab/fastify/issues/560)) ([73ed3b5](https://github.com/dzangolab/fastify/commit/73ed3b5926f6a581380128006ac348b7a7efb466)) - - +- **slonik:** support ssl for database connection ([#560](https://github.com/dzangolab/fastify/issues/560)) ([73ed3b5](https://github.com/dzangolab/fastify/commit/73ed3b5926f6a581380128006ac348b7a7efb466)) # [0.53.0](https://github.com/dzangolab/fastify/compare/v0.52.1...v0.53.0) (2023-11-20) ### BREAKING CHANGES -* Multi-tenant: Should Register MigrationPlugin from multi-tenant package to run tenant migrations from app. +- Multi-tenant: Should Register MigrationPlugin from multi-tenant package to run tenant migrations from app. Check Readme of @dzangolab/fastify-multi-tenant package. ### Bug Fixes -* **deps:** update dependency pg to v8.11.3 ([#460](https://github.com/dzangolab/fastify/issues/460)) ([e387afb](https://github.com/dzangolab/fastify/commit/e387afb35353fc828d5ff1dc6d3e103bb9a35cea)) - - +- **deps:** update dependency pg to v8.11.3 ([#460](https://github.com/dzangolab/fastify/issues/460)) ([e387afb](https://github.com/dzangolab/fastify/commit/e387afb35353fc828d5ff1dc6d3e103bb9a35cea)) ## [0.52.1](https://github.com/dzangolab/fastify/compare/v0.52.0...v0.52.1) (2023-11-17) ### Bug Fixes -* **deps:** update dependency slonik to v37.2.0 [security] ([#549](https://github.com/dzangolab/fastify/issues/549)) ([0dfab1b](https://github.com/dzangolab/fastify/commit/0dfab1b05d830d307484ea5be712b4c2e89ecb0e)) - +- **deps:** update dependency slonik to v37.2.0 [security] ([#549](https://github.com/dzangolab/fastify/issues/549)) ([0dfab1b](https://github.com/dzangolab/fastify/commit/0dfab1b05d830d307484ea5be712b4c2e89ecb0e)) # [0.52.0](https://github.com/dzangolab/fastify/compare/v0.51.1...v0.52.0) (2023-11-08) - ### Features -* **user:** make supertokens session recipe configurable ([#546](https://github.com/dzangolab/fastify/issues/546)) ([df02111](https://github.com/dzangolab/fastify/commit/df021110c6d93b1f819b661487da4245481d7f52)) - - +- **user:** make supertokens session recipe configurable ([#546](https://github.com/dzangolab/fastify/issues/546)) ([df02111](https://github.com/dzangolab/fastify/commit/df021110c6d93b1f819b661487da4245481d7f52)) ## [0.51.1](https://github.com/dzangolab/fastify/compare/v0.51.0...v0.51.1) (2023-11-07) - ### Features -* **user:** add user enable and disable graphql resolvers ([#545](https://github.com/dzangolab/fastify/issues/545)) ([1b7d0f8](https://github.com/dzangolab/fastify/commit/1b7d0f818d8b1ac0daeb0a7c7d9190d9f091e7a6)) - - +- **user:** add user enable and disable graphql resolvers ([#545](https://github.com/dzangolab/fastify/issues/545)) ([1b7d0f8](https://github.com/dzangolab/fastify/commit/1b7d0f818d8b1ac0daeb0a7c7d9190d9f091e7a6)) # [0.51.0](https://github.com/dzangolab/fastify/compare/v0.50.1...v0.51.0) (2023-11-05) - ### Features -* **user:** add admin routes to enable/disable user ([#535](https://github.com/dzangolab/fastify/issues/535)) ([b1e3252](https://github.com/dzangolab/fastify/commit/b1e3252ea8e5b9ac1f78c51d1f8fe6d3968066ad)) -* **user:** block protected routes to disabled users ([#542](https://github.com/dzangolab/fastify/issues/542)) ([34353d8](https://github.com/dzangolab/fastify/commit/34353d8010aad34cfafd63d14f80367eff1427aa)) -### Bug Fixes - -* **deps:** update dependency zod to v3.22.3 [security] ([#525](https://github.com/dzangolab/fastify/issues/525)) ([aaf3ac7](https://github.com/dzangolab/fastify/commit/aaf3ac7be3c23b05a7ab2f174116481d580e1836)) +- **user:** add admin routes to enable/disable user ([#535](https://github.com/dzangolab/fastify/issues/535)) ([b1e3252](https://github.com/dzangolab/fastify/commit/b1e3252ea8e5b9ac1f78c51d1f8fe6d3968066ad)) +- **user:** block protected routes to disabled users ([#542](https://github.com/dzangolab/fastify/issues/542)) ([34353d8](https://github.com/dzangolab/fastify/commit/34353d8010aad34cfafd63d14f80367eff1427aa)) +### Bug Fixes +- **deps:** update dependency zod to v3.22.3 [security] ([#525](https://github.com/dzangolab/fastify/issues/525)) ([aaf3ac7](https://github.com/dzangolab/fastify/commit/aaf3ac7be3c23b05a7ab2f174116481d580e1836)) ## [0.50.1](https://github.com/dzangolab/fastify/compare/v0.50.0...v0.50.1) (2023-10-31) - ### Features -* require session to verify email ([#531](https://github.com/dzangolab/fastify/issues/531)) ([6f44f40](https://github.com/dzangolab/fastify/commit/6f44f408b515182e32b1d36d3ddae4edd9b5b4d9)) -* **user:** make api and functions configurable of emailVerificationR… ([#533](https://github.com/dzangolab/fastify/issues/533)) ([5efe261](https://github.com/dzangolab/fastify/commit/5efe261f6582b282119ab6566d7692eca56839c4)) - - +- require session to verify email ([#531](https://github.com/dzangolab/fastify/issues/531)) ([6f44f40](https://github.com/dzangolab/fastify/commit/6f44f408b515182e32b1d36d3ddae4edd9b5b4d9)) +- **user:** make api and functions configurable of emailVerificationR… ([#533](https://github.com/dzangolab/fastify/issues/533)) ([5efe261](https://github.com/dzangolab/fastify/commit/5efe261f6582b282119ab6566d7692eca56839c4)) # [0.50.0](https://github.com/dzangolab/fastify/compare/v0.49.0...v0.50.0) (2023-10-09) - ### Features -* **fastify-user:** add apple redirect handler for android login and multi oauth provider support for apple ([#526](https://github.com/dzangolab/fastify/issues/526)) ([d0e54b3](https://github.com/dzangolab/fastify/commit/d0e54b3f0f51313e05069597b656dacd29e5e2dc)) - - +- **fastify-user:** add apple redirect handler for android login and multi oauth provider support for apple ([#526](https://github.com/dzangolab/fastify/issues/526)) ([d0e54b3](https://github.com/dzangolab/fastify/commit/d0e54b3f0f51313e05069597b656dacd29e5e2dc)) # [0.49.0](https://github.com/dzangolab/fastify/compare/v0.48.1...v0.49.0) (2023-10-03) - ### Features -* **slonik:** Run app migrations through migrationPlugin ([#508](https://github.com/dzangolab/fastify/issues/508)) ([905e25a](https://github.com/dzangolab/fastify/commit/905e25aa71739d5c303c7361886f42a70f07624a)) +- **slonik:** Run app migrations through migrationPlugin ([#508](https://github.com/dzangolab/fastify/issues/508)) ([905e25a](https://github.com/dzangolab/fastify/commit/905e25aa71739d5c303c7361886f42a70f07624a)) ### BREAKING CHANGES -* Slonik: Should Register MigrationPlugin from slonik package to run app migrations +- Slonik: Should Register MigrationPlugin from slonik package to run app migrations Check Readme of @dzangolab/fastify-slonik package. - ## [0.48.1](https://github.com/dzangolab/fastify/compare/v0.48.0...v0.48.1) (2023-09-26) - ### Bug Fixes -* export multipart parser plugin ([#520](https://github.com/dzangolab/fastify/issues/520)) ([fd7e833](https://github.com/dzangolab/fastify/commit/fd7e833521d73f1a4a96ef387317f3bdf11d0245)) - - +- export multipart parser plugin ([#520](https://github.com/dzangolab/fastify/issues/520)) ([fd7e833](https://github.com/dzangolab/fastify/commit/fd7e833521d73f1a4a96ef387317f3bdf11d0245)) # [0.48.0](https://github.com/dzangolab/fastify/compare/v0.47.0...v0.48.0) (2023-09-22) - ### Features -* graphql file upload on fastify-s3 ([#509](https://github.com/dzangolab/fastify/issues/509)) ([5d2220f](https://github.com/dzangolab/fastify/commit/5d2220f20feda49bb84e7dd61a305f197547f22b)) - - +- graphql file upload on fastify-s3 ([#509](https://github.com/dzangolab/fastify/issues/509)) ([5d2220f](https://github.com/dzangolab/fastify/commit/5d2220f20feda49bb84e7dd61a305f197547f22b)) # [0.47.0](https://github.com/dzangolab/fastify/compare/v0.46.0...v0.47.0) (2023-09-19) - ### Bug Fixes -* fix typo in filename resolution strategy ([#515](https://github.com/dzangolab/fastify/issues/515)) ([cb2a196](https://github.com/dzangolab/fastify/commit/cb2a196caea66746a8192f4132f1940419e1d49e)) -* update logic filename suffix to check exact filename ([#516](https://github.com/dzangolab/fastify/issues/516)) ([c1bad9a](https://github.com/dzangolab/fastify/commit/c1bad9a5ce7957a3318c1d788ec1c1f50c7d29f0)) - +- fix typo in filename resolution strategy ([#515](https://github.com/dzangolab/fastify/issues/515)) ([cb2a196](https://github.com/dzangolab/fastify/commit/cb2a196caea66746a8192f4132f1940419e1d49e)) +- update logic filename suffix to check exact filename ([#516](https://github.com/dzangolab/fastify/issues/516)) ([c1bad9a](https://github.com/dzangolab/fastify/commit/c1bad9a5ce7957a3318c1d788ec1c1f50c7d29f0)) ### Features -* update file fields on fastify-s3 ([#512](https://github.com/dzangolab/fastify/issues/512)) ([6e280c4](https://github.com/dzangolab/fastify/commit/6e280c4e460ae9fa3f81ca4f5ef11af088732709)) -* **user:** make handlers configurable ([#504](https://github.com/dzangolab/fastify/issues/504)) ([d1e6fb4](https://github.com/dzangolab/fastify/commit/d1e6fb42ec54ab07e691132731eb9fadd5772496)) - - +- update file fields on fastify-s3 ([#512](https://github.com/dzangolab/fastify/issues/512)) ([6e280c4](https://github.com/dzangolab/fastify/commit/6e280c4e460ae9fa3f81ca4f5ef11af088732709)) +- **user:** make handlers configurable ([#504](https://github.com/dzangolab/fastify/issues/504)) ([d1e6fb4](https://github.com/dzangolab/fastify/commit/d1e6fb42ec54ab07e691132731eb9fadd5772496)) # [0.46.0](https://github.com/dzangolab/fastify/compare/v0.45.0...v0.46.0) (2023-09-13) - ### Bug Fixes -* **user:** fix graphql issue when email not verified for public endpoint ([#493](https://github.com/dzangolab/fastify/issues/493)) ([964e2b4](https://github.com/dzangolab/fastify/commit/964e2b4095a8b760cb06ef87f170b23d06ed6b7e)) - +- **user:** fix graphql issue when email not verified for public endpoint ([#493](https://github.com/dzangolab/fastify/issues/493)) ([964e2b4](https://github.com/dzangolab/fastify/commit/964e2b4095a8b760cb06ef87f170b23d06ed6b7e)) ### Features -* add delete file method to file service on fastify-s3 ([#501](https://github.com/dzangolab/fastify/issues/501)) ([070f248](https://github.com/dzangolab/fastify/commit/070f248930f77af553d3539418cf836705ae2384)) - - +- add delete file method to file service on fastify-s3 ([#501](https://github.com/dzangolab/fastify/issues/501)) ([070f248](https://github.com/dzangolab/fastify/commit/070f248930f77af553d3539418cf836705ae2384)) # [0.45.0](https://github.com/dzangolab/fastify/compare/v0.44.0...v0.45.0) (2023-09-12) - ### Features -* add a function to check existing file on s3 bucket ([#496](https://github.com/dzangolab/fastify/issues/496)) ([e6ad3e6](https://github.com/dzangolab/fastify/commit/e6ad3e67368d107cf7ce04ba355ddcf060e5a2d5)) - - +- add a function to check existing file on s3 bucket ([#496](https://github.com/dzangolab/fastify/issues/496)) ([e6ad3e6](https://github.com/dzangolab/fastify/commit/e6ad3e67368d107cf7ce04ba355ddcf060e5a2d5)) # [0.44.0](https://github.com/dzangolab/fastify/compare/v0.43.0...v0.44.0) (2023-09-04) - ### Bug Fixes -* update vite config ([#492](https://github.com/dzangolab/fastify/issues/492)) ([4c87a42](https://github.com/dzangolab/fastify/commit/4c87a42d3846723c100c61f946f65505f59dfe1d)) - +- update vite config ([#492](https://github.com/dzangolab/fastify/issues/492)) ([4c87a42](https://github.com/dzangolab/fastify/commit/4c87a42d3846723c100c61f946f65505f59dfe1d)) ### Features -* **user:** add ability to auto verify email and send verification email on successful signup ([#489](https://github.com/dzangolab/fastify/issues/489)) ([e49490f](https://github.com/dzangolab/fastify/commit/e49490f0822b1804bf5479d639dbe084c4a4f80d)) - - +- **user:** add ability to auto verify email and send verification email on successful signup ([#489](https://github.com/dzangolab/fastify/issues/489)) ([e49490f](https://github.com/dzangolab/fastify/commit/e49490f0822b1804bf5479d639dbe084c4a4f80d)) # [0.43.0](https://github.com/dzangolab/fastify/compare/v0.42.0...v0.43.0) (2023-09-01) - ### Features -* update s3 package config and remove filename from params ([#486](https://github.com/dzangolab/fastify/issues/486)) ([0c076cf](https://github.com/dzangolab/fastify/commit/0c076cf7f6a4990e46f106be3e389eda9e9fff77)) -* **user:** add email verification recipe ([#482](https://github.com/dzangolab/fastify/issues/482)) ([3d24b17](https://github.com/dzangolab/fastify/commit/3d24b178b9377675b1c394c3b17ca3d0c79a66a2)) -* **user:** remove /auth path for email verification for app ([#487](https://github.com/dzangolab/fastify/issues/487)) ([800189b](https://github.com/dzangolab/fastify/commit/800189b962bb6d6694aabd5b0c7f458edb0aec99)) - - +- update s3 package config and remove filename from params ([#486](https://github.com/dzangolab/fastify/issues/486)) ([0c076cf](https://github.com/dzangolab/fastify/commit/0c076cf7f6a4990e46f106be3e389eda9e9fff77)) +- **user:** add email verification recipe ([#482](https://github.com/dzangolab/fastify/issues/482)) ([3d24b17](https://github.com/dzangolab/fastify/commit/3d24b178b9377675b1c394c3b17ca3d0c79a66a2)) +- **user:** remove /auth path for email verification for app ([#487](https://github.com/dzangolab/fastify/issues/487)) ([800189b](https://github.com/dzangolab/fastify/commit/800189b962bb6d6694aabd5b0c7f458edb0aec99)) # [0.42.0](https://github.com/dzangolab/fastify/compare/v0.41.0...v0.42.0) (2023-08-29) - ### Features -* **fastify-s3:** add s3 client to get all operation of aws s3 ([#467](https://github.com/dzangolab/fastify/issues/467)) ([391757e](https://github.com/dzangolab/fastify/commit/391757e3813a5c33204ee79853da9b05337e52bd)) - - +- **fastify-s3:** add s3 client to get all operation of aws s3 ([#467](https://github.com/dzangolab/fastify/issues/467)) ([391757e](https://github.com/dzangolab/fastify/commit/391757e3813a5c33204ee79853da9b05337e52bd)) # [0.41.0](https://github.com/dzangolab/fastify/compare/v0.40.2...v0.41.0) (2023-08-25) ### BREAKING CHANGES -* Only support supertokens CDI version 2.21 and greater +- Only support supertokens CDI version 2.21 and greater + +- This migration is required when upgrading -* This migration is required when upgrading ``` ALTER TABLE st__session_info ADD COLUMN IF NOT EXISTS use_static_key BOOLEAN NOT NULL DEFAULT(false); ALTER TABLE st__session_info ALTER COLUMN use_static_key DROP DEFAULT; ``` + Check this https://github.com/supertokens/supertokens-node/blob/master/CHANGELOG.md#1400---2023-05-04 to get more info on breaking changes related to supertokens. ## [0.40.2](https://github.com/dzangolab/fastify/compare/v0.40.1...v0.40.2) (2023-08-21) - ### Bug Fixes -* remove mailer from fastify request and graphql context ([#474](https://github.com/dzangolab/fastify/issues/474)) ([ebac4a7](https://github.com/dzangolab/fastify/commit/ebac4a784abd8308afa9635c8768f65c404c0a50)) - - +- remove mailer from fastify request and graphql context ([#474](https://github.com/dzangolab/fastify/issues/474)) ([ebac4a7](https://github.com/dzangolab/fastify/commit/ebac4a784abd8308afa9635c8768f65c404c0a50)) ## [0.40.1](https://github.com/dzangolab/fastify/compare/v0.40.0...v0.40.1) (2023-08-18) - ### Bug Fixes -* **user:** handle session errors in graphql context ([#470](https://github.com/dzangolab/fastify/issues/470)) ([cd4cf8c](https://github.com/dzangolab/fastify/commit/cd4cf8cfea1f5a6d5b8e63dd2d8b8a3f9ca8f322)) - - +- **user:** handle session errors in graphql context ([#470](https://github.com/dzangolab/fastify/issues/470)) ([cd4cf8c](https://github.com/dzangolab/fastify/commit/cd4cf8cfea1f5a6d5b8e63dd2d8b8a3f9ca8f322)) # [0.40.0](https://github.com/dzangolab/fastify/compare/v0.39.1...v0.40.0) (2023-08-17) - ### Features -* add plugin on fastify s3 ([#465](https://github.com/dzangolab/fastify/issues/465)) ([d827b0c](https://github.com/dzangolab/fastify/commit/d827b0c3086dd469b05aadef9c3f502b92405ef4)) -* added dzangolab/fastify-s3 package ([#464](https://github.com/dzangolab/fastify/issues/464)) ([f1f1e8c](https://github.com/dzangolab/fastify/commit/f1f1e8c2cc49b86ff79ee69c1bbc36e8299913ae)) -* **fastify-s3:** add a migration on plugin to create files table ([#466](https://github.com/dzangolab/fastify/issues/466)) ([a416eaf](https://github.com/dzangolab/fastify/commit/a416eafdccde1d4b6314a4d5b0b5496006ad8263)) -* **user:** generate invitation link based on app id or request origin ([#446](https://github.com/dzangolab/fastify/issues/446)) ([9824f46](https://github.com/dzangolab/fastify/commit/9824f46282bfdfeb0f826a5258e6f39185fb173a)) - - +- add plugin on fastify s3 ([#465](https://github.com/dzangolab/fastify/issues/465)) ([d827b0c](https://github.com/dzangolab/fastify/commit/d827b0c3086dd469b05aadef9c3f502b92405ef4)) +- added dzangolab/fastify-s3 package ([#464](https://github.com/dzangolab/fastify/issues/464)) ([f1f1e8c](https://github.com/dzangolab/fastify/commit/f1f1e8c2cc49b86ff79ee69c1bbc36e8299913ae)) +- **fastify-s3:** add a migration on plugin to create files table ([#466](https://github.com/dzangolab/fastify/issues/466)) ([a416eaf](https://github.com/dzangolab/fastify/commit/a416eafdccde1d4b6314a4d5b0b5496006ad8263)) +- **user:** generate invitation link based on app id or request origin ([#446](https://github.com/dzangolab/fastify/issues/446)) ([9824f46](https://github.com/dzangolab/fastify/commit/9824f46282bfdfeb0f826a5258e6f39185fb173a)) ## [0.39.1](https://github.com/dzangolab/fastify/compare/v0.39.0...v0.39.1) (2023-08-14) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.10.12 ([#436](https://github.com/dzangolab/fastify/issues/436)) ([9ef7b0a](https://github.com/dzangolab/fastify/commit/9ef7b0aa772406370ab643331f642bb00a191957)) -* **deps:** update dependency nodemailer to v6.9.4 ([#437](https://github.com/dzangolab/fastify/issues/437)) ([0737452](https://github.com/dzangolab/fastify/commit/0737452135465b632d210532cf7a8e144d6879bc)) - +- **deps:** update dependency eslint-config-turbo to v1.10.12 ([#436](https://github.com/dzangolab/fastify/issues/436)) ([9ef7b0a](https://github.com/dzangolab/fastify/commit/9ef7b0aa772406370ab643331f642bb00a191957)) +- **deps:** update dependency nodemailer to v6.9.4 ([#437](https://github.com/dzangolab/fastify/issues/437)) ([0737452](https://github.com/dzangolab/fastify/commit/0737452135465b632d210532cf7a8e144d6879bc)) ### Features -* **user:** first admin signup graphql resolver ([#457](https://github.com/dzangolab/fastify/issues/457)) ([aaccbd9](https://github.com/dzangolab/fastify/commit/aaccbd9ad1fbcdc4eec3f5bff5e6ea39c07e69c5)) - - +- **user:** first admin signup graphql resolver ([#457](https://github.com/dzangolab/fastify/issues/457)) ([aaccbd9](https://github.com/dzangolab/fastify/commit/aaccbd9ad1fbcdc4eec3f5bff5e6ea39c07e69c5)) # [0.39.0](https://github.com/dzangolab/fastify/compare/v0.38.0...v0.39.0) (2023-08-11) - ### Features -* **config:** add apps config in apiConfig ([#429](https://github.com/dzangolab/fastify/issues/429)) ([22d7eed](https://github.com/dzangolab/fastify/commit/22d7eedac81d376f710d7cac01fd2ecf299b44bf)) - - +- **config:** add apps config in apiConfig ([#429](https://github.com/dzangolab/fastify/issues/429)) ([22d7eed](https://github.com/dzangolab/fastify/commit/22d7eedac81d376f710d7cac01fd2ecf299b44bf)) # [0.38.0](https://github.com/dzangolab/fastify/compare/v0.37.1...v0.38.0) (2023-08-09) - - ## [0.37.1](https://github.com/dzangolab/fastify/compare/v0.37.0...v0.37.1) (2023-08-07) - - # [0.37.0](https://github.com/dzangolab/fastify/compare/v0.36.2...v0.37.0) (2023-08-02) - ### Bug Fixes -* **slonik:** fix factory getter method in service ([0a4b013](https://github.com/dzangolab/fastify/commit/0a4b0135e890324561df6a744da84922499f62c8)) - +- **slonik:** fix factory getter method in service ([0a4b013](https://github.com/dzangolab/fastify/commit/0a4b0135e890324561df6a744da84922499f62c8)) ### Features -* **user:** add post accept invitation config ([#442](https://github.com/dzangolab/fastify/issues/442)) ([2e8bb39](https://github.com/dzangolab/fastify/commit/2e8bb397bc9ed29f2a3b622a6c632485d78a3714)) -* **user:** add User in invitation list method ([#445](https://github.com/dzangolab/fastify/issues/445)) ([44bd832](https://github.com/dzangolab/fastify/commit/44bd832646ec01759c7ac21f0d05cf229c71bb0f)) -* **user:** graphql endpoints for invitation ([#440](https://github.com/dzangolab/fastify/issues/440)) ([8d50ab9](https://github.com/dzangolab/fastify/commit/8d50ab9e1314708dbe20daab912198ede4d7c9de)) - - +- **user:** add post accept invitation config ([#442](https://github.com/dzangolab/fastify/issues/442)) ([2e8bb39](https://github.com/dzangolab/fastify/commit/2e8bb397bc9ed29f2a3b622a6c632485d78a3714)) +- **user:** add User in invitation list method ([#445](https://github.com/dzangolab/fastify/issues/445)) ([44bd832](https://github.com/dzangolab/fastify/commit/44bd832646ec01759c7ac21f0d05cf229c71bb0f)) +- **user:** graphql endpoints for invitation ([#440](https://github.com/dzangolab/fastify/issues/440)) ([8d50ab9](https://github.com/dzangolab/fastify/commit/8d50ab9e1314708dbe20daab912198ede4d7c9de)) ## [0.36.2](https://github.com/dzangolab/fastify/compare/v0.36.1...v0.36.2) (2023-07-28) - ### Performance Improvements -* **user:** remove invitation token in list handler ([#441](https://github.com/dzangolab/fastify/issues/441)) ([c68cb8d](https://github.com/dzangolab/fastify/commit/c68cb8dbfedf11768e020bac74cccf9a3926a14a)) - - +- **user:** remove invitation token in list handler ([#441](https://github.com/dzangolab/fastify/issues/441)) ([c68cb8d](https://github.com/dzangolab/fastify/commit/c68cb8dbfedf11768e020bac74cccf9a3926a14a)) ## [0.36.1](https://github.com/dzangolab/fastify/compare/v0.36.0...v0.36.1) (2023-07-25) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.62.0 ([#414](https://github.com/dzangolab/fastify/issues/414)) ([1aa60b1](https://github.com/dzangolab/fastify/commit/1aa60b1623d9ade692a14f47337a1ac209b42698)) - - +- **deps:** update typescript-eslint monorepo to v5.62.0 ([#414](https://github.com/dzangolab/fastify/issues/414)) ([1aa60b1](https://github.com/dzangolab/fastify/commit/1aa60b1623d9ade692a14f47337a1ac209b42698)) # [0.36.0](https://github.com/dzangolab/fastify/compare/v0.35.0...v0.36.0) (2023-07-24) - ### Features -* **user:** revoke invitation ([#426](https://github.com/dzangolab/fastify/issues/426)) ([7d14c60](https://github.com/dzangolab/fastify/commit/7d14c601ede5e67e9c8a5bc71b217dbceb3fa243)) -* **user:** throw error while create invitation if already have valid invitation in database. ([#433](https://github.com/dzangolab/fastify/issues/433)) ([c5caa8a](https://github.com/dzangolab/fastify/commit/c5caa8a773d7507069b4b023a0c051dfedae8e82)) - - +- **user:** revoke invitation ([#426](https://github.com/dzangolab/fastify/issues/426)) ([7d14c60](https://github.com/dzangolab/fastify/commit/7d14c601ede5e67e9c8a5bc71b217dbceb3fa243)) +- **user:** throw error while create invitation if already have valid invitation in database. ([#433](https://github.com/dzangolab/fastify/issues/433)) ([c5caa8a](https://github.com/dzangolab/fastify/commit/c5caa8a773d7507069b4b023a0c051dfedae8e82)) # [0.35.0](https://github.com/dzangolab/fastify/compare/v0.34.0...v0.35.0) (2023-07-21) - ### Features -* **user:** Add get invitation by token endpoint ([#424](https://github.com/dzangolab/fastify/issues/424)) ([2ff38d2](https://github.com/dzangolab/fastify/commit/2ff38d262eb81f65819028494a72d39b647c3cf6)) -* **user:** add list invitatons controller ([#428](https://github.com/dzangolab/fastify/issues/428)) ([2716b29](https://github.com/dzangolab/fastify/commit/2716b2927cadf5b387d6c86d4c447550659f540b)) - - +- **user:** Add get invitation by token endpoint ([#424](https://github.com/dzangolab/fastify/issues/424)) ([2ff38d2](https://github.com/dzangolab/fastify/commit/2ff38d262eb81f65819028494a72d39b647c3cf6)) +- **user:** add list invitatons controller ([#428](https://github.com/dzangolab/fastify/issues/428)) ([2716b29](https://github.com/dzangolab/fastify/commit/2716b2927cadf5b387d6c86d4c447550659f540b)) # [0.34.0](https://github.com/dzangolab/fastify/compare/v0.33.0...v0.34.0) (2023-07-19) - ### Features -* **user:** create invitation ([#423](https://github.com/dzangolab/fastify/issues/423)) ([ed8dcab](https://github.com/dzangolab/fastify/commit/ed8dcabeecea54e6ef4c02d81083df8fd7271db6)) - - +- **user:** create invitation ([#423](https://github.com/dzangolab/fastify/issues/423)) ([ed8dcab](https://github.com/dzangolab/fastify/commit/ed8dcabeecea54e6ef4c02d81083df8fd7271db6)) # [0.33.0](https://github.com/dzangolab/fastify/compare/v0.32.10...v0.33.0) (2023-06-27) - ### Features -* **user:** upgrade supertokens node to 13.6.0 ([#419](https://github.com/dzangolab/fastify/issues/419)) ([c91034a](https://github.com/dzangolab/fastify/commit/c91034adca754baf746ba22132583851e123ce3e)) - - +- **user:** upgrade supertokens node to 13.6.0 ([#419](https://github.com/dzangolab/fastify/issues/419)) ([c91034a](https://github.com/dzangolab/fastify/commit/c91034adca754baf746ba22132583851e123ce3e)) ## [0.32.10](https://github.com/dzangolab/fastify/compare/v0.32.9...v0.32.10) (2023-06-19) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.10.3 ([#410](https://github.com/dzangolab/fastify/issues/410)) ([e2a8e2a](https://github.com/dzangolab/fastify/commit/e2a8e2aeb17a3d91a7e06c27d7c58495dfabd784)) -* **deps:** update dependency nodemailer-mjml to v1.2.24 ([#407](https://github.com/dzangolab/fastify/issues/407)) ([acb8499](https://github.com/dzangolab/fastify/commit/acb849982b32f423f210fb3b65190b156093460c)) -* **deps:** update dependency pg to v8.11.0 ([#178](https://github.com/dzangolab/fastify/issues/178)) ([a59d4c0](https://github.com/dzangolab/fastify/commit/a59d4c023fd37c2505a55b5874f70457ed79c861)) -* **deps:** update dependency vue-eslint-parser to v9.3.1 ([#412](https://github.com/dzangolab/fastify/issues/412)) ([e2255b5](https://github.com/dzangolab/fastify/commit/e2255b50cc7521d3b399cd108dff397c40d2f4b4)) -* **deps:** update typescript-eslint monorepo to v5.59.11 ([#370](https://github.com/dzangolab/fastify/issues/370)) ([614a85d](https://github.com/dzangolab/fastify/commit/614a85dc50c513c00e7f068f136edbaa5f2e403d)) - - +- **deps:** update dependency eslint-config-turbo to v1.10.3 ([#410](https://github.com/dzangolab/fastify/issues/410)) ([e2a8e2a](https://github.com/dzangolab/fastify/commit/e2a8e2aeb17a3d91a7e06c27d7c58495dfabd784)) +- **deps:** update dependency nodemailer-mjml to v1.2.24 ([#407](https://github.com/dzangolab/fastify/issues/407)) ([acb8499](https://github.com/dzangolab/fastify/commit/acb849982b32f423f210fb3b65190b156093460c)) +- **deps:** update dependency pg to v8.11.0 ([#178](https://github.com/dzangolab/fastify/issues/178)) ([a59d4c0](https://github.com/dzangolab/fastify/commit/a59d4c023fd37c2505a55b5874f70457ed79c861)) +- **deps:** update dependency vue-eslint-parser to v9.3.1 ([#412](https://github.com/dzangolab/fastify/issues/412)) ([e2255b5](https://github.com/dzangolab/fastify/commit/e2255b50cc7521d3b399cd108dff397c40d2f4b4)) +- **deps:** update typescript-eslint monorepo to v5.59.11 ([#370](https://github.com/dzangolab/fastify/issues/370)) ([614a85d](https://github.com/dzangolab/fastify/commit/614a85dc50c513c00e7f068f136edbaa5f2e403d)) ## [0.32.9](https://github.com/dzangolab/fastify/compare/v0.32.8...v0.32.9) (2023-06-15) - ### Features -* **slonik:** run package migrations ([#374](https://github.com/dzangolab/fastify/issues/374)) ([9e45ff0](https://github.com/dzangolab/fastify/commit/9e45ff0381765a6aa851b7486b2f754b9ec180d4)) -* **user:** update user details ([#338](https://github.com/dzangolab/fastify/issues/338)) ([eabf0cd](https://github.com/dzangolab/fastify/commit/eabf0cdb4bc2272e5867f70a0daad13410550f05)) - - +- **slonik:** run package migrations ([#374](https://github.com/dzangolab/fastify/issues/374)) ([9e45ff0](https://github.com/dzangolab/fastify/commit/9e45ff0381765a6aa851b7486b2f754b9ec180d4)) +- **user:** update user details ([#338](https://github.com/dzangolab/fastify/issues/338)) ([eabf0cd](https://github.com/dzangolab/fastify/commit/eabf0cdb4bc2272e5867f70a0daad13410550f05)) ## [0.32.8](https://github.com/dzangolab/fastify/compare/v0.32.7...v0.32.8) (2023-06-07) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.3 ([#378](https://github.com/dzangolab/fastify/issues/378)) ([ef911d6](https://github.com/dzangolab/fastify/commit/ef911d6b28857f217a43693f0ede9f30acffb5cb)) -* **deps:** update dependency nodemailer-mjml to v1.2.22 ([#379](https://github.com/dzangolab/fastify/issues/379)) ([fc809bb](https://github.com/dzangolab/fastify/commit/fc809bb8c8c87f890d10a164c6cb49998fb0a42a)) - +- **deps:** update dependency nodemailer to v6.9.3 ([#378](https://github.com/dzangolab/fastify/issues/378)) ([ef911d6](https://github.com/dzangolab/fastify/commit/ef911d6b28857f217a43693f0ede9f30acffb5cb)) +- **deps:** update dependency nodemailer-mjml to v1.2.22 ([#379](https://github.com/dzangolab/fastify/issues/379)) ([fc809bb](https://github.com/dzangolab/fastify/commit/fc809bb8c8c87f890d10a164c6cb49998fb0a42a)) ### Features -* **user:** send reset password success email to user ([#400](https://github.com/dzangolab/fastify/issues/400)) ([4b1d7d7](https://github.com/dzangolab/fastify/commit/4b1d7d7b2acd265bbc1532550933aa5685105377)) - - +- **user:** send reset password success email to user ([#400](https://github.com/dzangolab/fastify/issues/400)) ([4b1d7d7](https://github.com/dzangolab/fastify/commit/4b1d7d7b2acd265bbc1532550933aa5685105377)) ## [0.32.7](https://github.com/dzangolab/fastify/compare/v0.32.6...v0.32.7) (2023-06-01) - ### Features -* **user:** add role validation for sign up ([#391](https://github.com/dzangolab/fastify/issues/391)) ([dbb15db](https://github.com/dzangolab/fastify/commit/dbb15db4e24fac26abb142a19e4dc2690bc1d080)) - - +- **user:** add role validation for sign up ([#391](https://github.com/dzangolab/fastify/issues/391)) ([dbb15db](https://github.com/dzangolab/fastify/commit/dbb15db4e24fac26abb142a19e4dc2690bc1d080)) ## [0.32.6](https://github.com/dzangolab/fastify/compare/v0.32.5...v0.32.6) (2023-05-31) - - ## [0.32.5](https://github.com/dzangolab/fastify/compare/v0.32.4...v0.32.5) (2023-05-26) - ### Bug Fixes -* send email asyncronously ([206c547](https://github.com/dzangolab/fastify/commit/206c547daaf5c7ef77a229a5218f8ba6d4e3ab14)) - +- send email asyncronously ([206c547](https://github.com/dzangolab/fastify/commit/206c547daaf5c7ef77a229a5218f8ba6d4e3ab14)) ### Features -* **user:** add roles in users endpoint ([#389](https://github.com/dzangolab/fastify/issues/389)) ([1fe8648](https://github.com/dzangolab/fastify/commit/1fe8648b205800ad678410dc1030fd25ce22de92)) -* **user:** export email and password validation from user package ([#392](https://github.com/dzangolab/fastify/issues/392)) ([5c9610f](https://github.com/dzangolab/fastify/commit/5c9610fe3cc8d9de3a48f2e89ee4fa8206f356cb)) - - +- **user:** add roles in users endpoint ([#389](https://github.com/dzangolab/fastify/issues/389)) ([1fe8648](https://github.com/dzangolab/fastify/commit/1fe8648b205800ad678410dc1030fd25ce22de92)) +- **user:** export email and password validation from user package ([#392](https://github.com/dzangolab/fastify/issues/392)) ([5c9610f](https://github.com/dzangolab/fastify/commit/5c9610fe3cc8d9de3a48f2e89ee4fa8206f356cb)) ## [0.32.4](https://github.com/dzangolab/fastify/compare/v0.32.3...v0.32.4) (2023-05-17) - ### Features -* **slonik:** add support camelCase in sort and filter query ([#386](https://github.com/dzangolab/fastify/issues/386)) ([cb0a228](https://github.com/dzangolab/fastify/commit/cb0a228946764d2f7f527eef20d3880d110c22c8)) - - +- **slonik:** add support camelCase in sort and filter query ([#386](https://github.com/dzangolab/fastify/issues/386)) ([cb0a228](https://github.com/dzangolab/fastify/commit/cb0a228946764d2f7f527eef20d3880d110c22c8)) ## [0.32.3](https://github.com/dzangolab/fastify/compare/v0.32.2...v0.32.3) (2023-05-17) - ### Features -* **slonik:** Support case for IS NULL and IS NOT NULL in FilterInput ([#383](https://github.com/dzangolab/fastify/issues/383)) ([69936ec](https://github.com/dzangolab/fastify/commit/69936eced4aec2d1b38862b8a78e28cc7456066e)) - - +- **slonik:** Support case for IS NULL and IS NOT NULL in FilterInput ([#383](https://github.com/dzangolab/fastify/issues/383)) ([69936ec](https://github.com/dzangolab/fastify/commit/69936eced4aec2d1b38862b8a78e28cc7456066e)) ## [0.32.2](https://github.com/dzangolab/fastify/compare/v0.32.1...v0.32.2) (2023-05-16) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.9.4 ([#376](https://github.com/dzangolab/fastify/issues/376)) ([d8e302f](https://github.com/dzangolab/fastify/commit/d8e302feb1c8ff692e939d90badcb371fcd6f48d)) -* **deps:** update dependency eslint-plugin-unicorn to v46.0.1 ([#362](https://github.com/dzangolab/fastify/issues/362)) ([1c00080](https://github.com/dzangolab/fastify/commit/1c000801d2eafa0b0d16deaf57853830ddeda613)) -* **slonik:** fix sorting issue for all and list query ([#377](https://github.com/dzangolab/fastify/issues/377)) ([8443a29](https://github.com/dzangolab/fastify/commit/8443a291b1138666b46b0b516086f1932408f63a)) - - +- **deps:** update dependency eslint-config-turbo to v1.9.4 ([#376](https://github.com/dzangolab/fastify/issues/376)) ([d8e302f](https://github.com/dzangolab/fastify/commit/d8e302feb1c8ff692e939d90badcb371fcd6f48d)) +- **deps:** update dependency eslint-plugin-unicorn to v46.0.1 ([#362](https://github.com/dzangolab/fastify/issues/362)) ([1c00080](https://github.com/dzangolab/fastify/commit/1c000801d2eafa0b0d16deaf57853830ddeda613)) +- **slonik:** fix sorting issue for all and list query ([#377](https://github.com/dzangolab/fastify/issues/377)) ([8443a29](https://github.com/dzangolab/fastify/commit/8443a291b1138666b46b0b516086f1932408f63a)) ## [0.32.1](https://github.com/dzangolab/fastify/compare/v0.32.0...v0.32.1) (2023-05-11) - ### Bug Fixes -* **multi-tenant:** fix change password issue ([#372](https://github.com/dzangolab/fastify/issues/372)) ([41154df](https://github.com/dzangolab/fastify/commit/41154df9ad7480d99cd6f1e87566eb9cbd5dfa7a)) - - +- **multi-tenant:** fix change password issue ([#372](https://github.com/dzangolab/fastify/issues/372)) ([41154df](https://github.com/dzangolab/fastify/commit/41154df9ad7480d99cd6f1e87566eb9cbd5dfa7a)) # [0.32.0](https://github.com/dzangolab/fastify/compare/v0.31.3...v0.32.0) (2023-05-10) - ### Features -* Multi tenant authentication ([#355](https://github.com/dzangolab/fastify/issues/355)) ([9b3f6d7](https://github.com/dzangolab/fastify/commit/9b3f6d745e781edf1051e9febf7dc1e2c9e8758e)) - - +- Multi tenant authentication ([#355](https://github.com/dzangolab/fastify/issues/355)) ([9b3f6d7](https://github.com/dzangolab/fastify/commit/9b3f6d745e781edf1051e9febf7dc1e2c9e8758e)) ## [0.31.3](https://github.com/dzangolab/fastify/compare/v0.31.2...v0.31.3) (2023-05-08) - ### Bug Fixes -* **deps:** update dependency nodemailer-mjml to v1.2.18 ([#353](https://github.com/dzangolab/fastify/issues/353)) ([beceef4](https://github.com/dzangolab/fastify/commit/beceef4f42630cf1262249299c616de5f567150a)) -* **deps:** update typescript-eslint monorepo to v5.59.2 ([#344](https://github.com/dzangolab/fastify/issues/344)) ([ed3bc26](https://github.com/dzangolab/fastify/commit/ed3bc267d41a1cb5e5e1540a950e2d0121a1aba4)) - - +- **deps:** update dependency nodemailer-mjml to v1.2.18 ([#353](https://github.com/dzangolab/fastify/issues/353)) ([beceef4](https://github.com/dzangolab/fastify/commit/beceef4f42630cf1262249299c616de5f567150a)) +- **deps:** update typescript-eslint monorepo to v5.59.2 ([#344](https://github.com/dzangolab/fastify/issues/344)) ([ed3bc26](https://github.com/dzangolab/fastify/commit/ed3bc267d41a1cb5e5e1540a950e2d0121a1aba4)) ## [0.31.2](https://github.com/dzangolab/fastify/compare/v0.31.1...v0.31.2) (2023-05-05) ### Features -* **mailer:** decorate mailer in fastify request and graphql context ([#357](https://github.com/dzangolab/fastify/issues/357)) ([5cf086d](https://github.com/dzangolab/fastify/commit/5cf086d3c2f1c4bfa4bcf73bb7517e976503cc12)) - +- **mailer:** decorate mailer in fastify request and graphql context ([#357](https://github.com/dzangolab/fastify/issues/357)) ([5cf086d](https://github.com/dzangolab/fastify/commit/5cf086d3c2f1c4bfa4bcf73bb7517e976503cc12)) ## [0.31.1](https://github.com/dzangolab/fastify/compare/v0.31.0...v0.31.1) (2023-04-26) - - # [0.31.0](https://github.com/dzangolab/fastify/compare/v0.30.0...v0.31.0) (2023-04-25) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.9.3 ([#343](https://github.com/dzangolab/fastify/issues/343)) ([8c09e84](https://github.com/dzangolab/fastify/commit/8c09e84144f862a677cbe5ac7b36e3ef871ea8c4)) - +- **deps:** update dependency eslint-config-turbo to v1.9.3 ([#343](https://github.com/dzangolab/fastify/issues/343)) ([8c09e84](https://github.com/dzangolab/fastify/commit/8c09e84144f862a677cbe5ac7b36e3ef871ea8c4)) ### Features -* change user profile to user ([#349](https://github.com/dzangolab/fastify/issues/349)) ([9a94d99](https://github.com/dzangolab/fastify/commit/9a94d99de681275c30ae39361cd40dc8ebb65195)) +- change user profile to user ([#349](https://github.com/dzangolab/fastify/issues/349)) ([9a94d99](https://github.com/dzangolab/fastify/commit/9a94d99de681275c30ae39361cd40dc8ebb65195)) ### BREAKING CHANGES -* (user): removed profile and roles from signin and signup auth response. -* (user): added signedUpAt and lastLoginAt property to User - - +- (user): removed profile and roles from signin and signup auth response. +- (user): added signedUpAt and lastLoginAt property to User # [0.30.0](https://github.com/dzangolab/fastify/compare/v0.29.0...v0.30.0) (2023-04-13) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v1.9.1 ([#335](https://github.com/dzangolab/fastify/issues/335)) ([37c53d7](https://github.com/dzangolab/fastify/commit/37c53d70531c4a20066badbcf87d49dd8dcce88d)) -* **deps:** update typescript-eslint monorepo to v5.58.0 ([#331](https://github.com/dzangolab/fastify/issues/331)) ([79a0e59](https://github.com/dzangolab/fastify/commit/79a0e598b7190469cc630a6a5d7bb462fc0914a1)) - +- **deps:** update dependency eslint-config-turbo to v1.9.1 ([#335](https://github.com/dzangolab/fastify/issues/335)) ([37c53d7](https://github.com/dzangolab/fastify/commit/37c53d70531c4a20066badbcf87d49dd8dcce88d)) +- **deps:** update typescript-eslint monorepo to v5.58.0 ([#331](https://github.com/dzangolab/fastify/issues/331)) ([79a0e59](https://github.com/dzangolab/fastify/commit/79a0e598b7190469cc630a6a5d7bb462fc0914a1)) ### Features -* **slonik:** remove paginatedList ([#305](https://github.com/dzangolab/fastify/issues/305)) ([8757b53](https://github.com/dzangolab/fastify/commit/8757b535d5f9d442a319129ffd87f960c2107657)) -* **user:** customizable signUpFeature in supertoken's third party email password recipe ([#332](https://github.com/dzangolab/fastify/issues/332)) ([6241374](https://github.com/dzangolab/fastify/commit/62413743e442f3a344be872fa85f8eca885750e6)) - - +- **slonik:** remove paginatedList ([#305](https://github.com/dzangolab/fastify/issues/305)) ([8757b53](https://github.com/dzangolab/fastify/commit/8757b535d5f9d442a319129ffd87f960c2107657)) +- **user:** customizable signUpFeature in supertoken's third party email password recipe ([#332](https://github.com/dzangolab/fastify/issues/332)) ([6241374](https://github.com/dzangolab/fastify/commit/62413743e442f3a344be872fa85f8eca885750e6)) # [0.29.0](https://github.com/dzangolab/fastify/compare/v0.28.0...v0.29.0) (2023-04-12) - ### Bug Fixes -* **deps:** update dependency eslint-config-prettier to v8.8.0 ([#315](https://github.com/dzangolab/fastify/issues/315)) ([5d28a05](https://github.com/dzangolab/fastify/commit/5d28a05fd8184b0b4e4773c2b7061d16693f317e)) -* **deps:** update dependency eslint-config-turbo to v1 ([#321](https://github.com/dzangolab/fastify/issues/321)) ([fedb91f](https://github.com/dzangolab/fastify/commit/fedb91f2333e2ccb16c19dbc26a090a13f1ff1c9)) -* **deps:** update dependency eslint-import-resolver-typescript to v3.5.5 ([#327](https://github.com/dzangolab/fastify/issues/327)) ([f48a187](https://github.com/dzangolab/fastify/commit/f48a187ca133c18aea9cb7ca62e5630891ffdadd)) -* **deps:** update dependency eslint-plugin-unicorn to v46 ([#322](https://github.com/dzangolab/fastify/issues/322)) ([f789f2b](https://github.com/dzangolab/fastify/commit/f789f2b134d445958e40ff81033970a7f8846bce)) -* **deps:** update dependency nodemailer-mjml to v1.2.15 ([#317](https://github.com/dzangolab/fastify/issues/317)) ([5852d42](https://github.com/dzangolab/fastify/commit/5852d426b0a9bcb8df0de16aa205b594c6b00dd7)) - +- **deps:** update dependency eslint-config-prettier to v8.8.0 ([#315](https://github.com/dzangolab/fastify/issues/315)) ([5d28a05](https://github.com/dzangolab/fastify/commit/5d28a05fd8184b0b4e4773c2b7061d16693f317e)) +- **deps:** update dependency eslint-config-turbo to v1 ([#321](https://github.com/dzangolab/fastify/issues/321)) ([fedb91f](https://github.com/dzangolab/fastify/commit/fedb91f2333e2ccb16c19dbc26a090a13f1ff1c9)) +- **deps:** update dependency eslint-import-resolver-typescript to v3.5.5 ([#327](https://github.com/dzangolab/fastify/issues/327)) ([f48a187](https://github.com/dzangolab/fastify/commit/f48a187ca133c18aea9cb7ca62e5630891ffdadd)) +- **deps:** update dependency eslint-plugin-unicorn to v46 ([#322](https://github.com/dzangolab/fastify/issues/322)) ([f789f2b](https://github.com/dzangolab/fastify/commit/f789f2b134d445958e40ff81033970a7f8846bce)) +- **deps:** update dependency nodemailer-mjml to v1.2.15 ([#317](https://github.com/dzangolab/fastify/issues/317)) ([5852d42](https://github.com/dzangolab/fastify/commit/5852d426b0a9bcb8df0de16aa205b594c6b00dd7)) ### Features -* **user:** make third party email password recipe functions/apis customizable from user config ([#316](https://github.com/dzangolab/fastify/issues/316)) ([b5fc939](https://github.com/dzangolab/fastify/commit/b5fc939b1fa9476ddfd2583049c3c9639bfdf783)) - - +- **user:** make third party email password recipe functions/apis customizable from user config ([#316](https://github.com/dzangolab/fastify/issues/316)) ([b5fc939](https://github.com/dzangolab/fastify/commit/b5fc939b1fa9476ddfd2583049c3c9639bfdf783)) # [0.28.0](https://github.com/dzangolab/fastify/compare/v0.27.1...v0.28.0) (2023-04-11) ### BREAKING CHANGES -* **user:** remove user object from session token ([#302](https://github.com/dzangolab/fastify/issues/247)) ([c1c0e7f](https://github.com/dzangolab/fastify/commit/c1c0e7f0e6bec30ad45981161ff3e043c7927fc7)) +- **user:** remove user object from session token ([#302](https://github.com/dzangolab/fastify/issues/247)) ([c1c0e7f](https://github.com/dzangolab/fastify/commit/c1c0e7f0e6bec30ad45981161ff3e043c7927fc7)) ## [0.27.1](https://github.com/dzangolab/fastify/compare/v0.27.0...v0.27.1) (2023-04-05) - - # [0.27.0](https://github.com/dzangolab/fastify/compare/v0.26.3...v0.27.0) (2023-04-04) - ### Bug Fixes -* **deps:** update dependency vue-eslint-parser to v9.1.1 ([#303](https://github.com/dzangolab/fastify/issues/303)) ([9393642](https://github.com/dzangolab/fastify/commit/9393642432a68fb94fda502b243d44979c301ebc)) -* **deps:** update typescript-eslint monorepo to v5.57.1 ([#309](https://github.com/dzangolab/fastify/issues/309)) ([610c03c](https://github.com/dzangolab/fastify/commit/610c03c1af1a0343c98110a64b6baea6c09a4e45)) - +- **deps:** update dependency vue-eslint-parser to v9.1.1 ([#303](https://github.com/dzangolab/fastify/issues/303)) ([9393642](https://github.com/dzangolab/fastify/commit/9393642432a68fb94fda502b243d44979c301ebc)) +- **deps:** update typescript-eslint monorepo to v5.57.1 ([#309](https://github.com/dzangolab/fastify/issues/309)) ([610c03c](https://github.com/dzangolab/fastify/commit/610c03c1af1a0343c98110a64b6baea6c09a4e45)) ### BREAKING CHANGES -* **slonik:** update list method of service class ([#302](https://github.com/dzangolab/fastify/issues/302)) ([8f7f83f](https://github.com/dzangolab/fastify/commit/8f7f83ff2ceef73bbc0dc67eb65616821dca70d2)) - - +- **slonik:** update list method of service class ([#302](https://github.com/dzangolab/fastify/issues/302)) ([8f7f83f](https://github.com/dzangolab/fastify/commit/8f7f83ff2ceef73bbc0dc67eb65616821dca70d2)) ## [0.26.3](https://github.com/dzangolab/fastify/compare/v0.26.2...v0.26.3) (2023-04-03) - ### Bug Fixes -* **deps:** update dependency eslint-import-resolver-typescript to v3.5.4 ([#295](https://github.com/dzangolab/fastify/issues/295)) ([8de8f4b](https://github.com/dzangolab/fastify/commit/8de8f4bad1fe53510afdc96298c1075a9c55a08e)) - - +- **deps:** update dependency eslint-import-resolver-typescript to v3.5.4 ([#295](https://github.com/dzangolab/fastify/issues/295)) ([8de8f4b](https://github.com/dzangolab/fastify/commit/8de8f4bad1fe53510afdc96298c1075a9c55a08e)) ## [0.26.2](https://github.com/dzangolab/fastify/compare/v0.26.1...v0.26.2) (2023-03-30) - ### Bug Fixes -* fix zod validation for getAllSql query ([#294](https://github.com/dzangolab/fastify/issues/294)) ([4d239d0](https://github.com/dzangolab/fastify/commit/4d239d0324386e422c9073997644c46c0cda45d8)) - - +- fix zod validation for getAllSql query ([#294](https://github.com/dzangolab/fastify/issues/294)) ([4d239d0](https://github.com/dzangolab/fastify/commit/4d239d0324386e422c9073997644c46c0cda45d8)) ## [0.26.1](https://github.com/dzangolab/fastify/compare/v0.26.0...v0.26.1) (2023-03-29) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v0.0.10 ([#287](https://github.com/dzangolab/fastify/issues/287)) ([4ce0514](https://github.com/dzangolab/fastify/commit/4ce0514681f1f25cf6921ed027579a45e03dbf2f)) -* **deps:** update dependency html-to-text to v9.0.5 ([#288](https://github.com/dzangolab/fastify/issues/288)) ([878830e](https://github.com/dzangolab/fastify/commit/878830e47422f6aa0e0347270d2edd0d4521ea56)) -* **deps:** update dependency nodemailer-mjml to v1.2.13 ([#196](https://github.com/dzangolab/fastify/issues/196)) ([7692ad4](https://github.com/dzangolab/fastify/commit/7692ad4d07da1cf32ce2f18bece6415adef51020)) -* **deps:** update typescript-eslint monorepo to v5.57.0 ([#216](https://github.com/dzangolab/fastify/issues/216)) ([ac92830](https://github.com/dzangolab/fastify/commit/ac928305abf39c596d91c4d6d8730b34729f1c7f)) -* **user:** return paginated user list on users endpoint ([#283](https://github.com/dzangolab/fastify/issues/283)) ([00954dc](https://github.com/dzangolab/fastify/commit/00954dc681f6a8ecce8f483511967bd3b50d05c8)) - - +- **deps:** update dependency eslint-config-turbo to v0.0.10 ([#287](https://github.com/dzangolab/fastify/issues/287)) ([4ce0514](https://github.com/dzangolab/fastify/commit/4ce0514681f1f25cf6921ed027579a45e03dbf2f)) +- **deps:** update dependency html-to-text to v9.0.5 ([#288](https://github.com/dzangolab/fastify/issues/288)) ([878830e](https://github.com/dzangolab/fastify/commit/878830e47422f6aa0e0347270d2edd0d4521ea56)) +- **deps:** update dependency nodemailer-mjml to v1.2.13 ([#196](https://github.com/dzangolab/fastify/issues/196)) ([7692ad4](https://github.com/dzangolab/fastify/commit/7692ad4d07da1cf32ce2f18bece6415adef51020)) +- **deps:** update typescript-eslint monorepo to v5.57.0 ([#216](https://github.com/dzangolab/fastify/issues/216)) ([ac92830](https://github.com/dzangolab/fastify/commit/ac928305abf39c596d91c4d6d8730b34729f1c7f)) +- **user:** return paginated user list on users endpoint ([#283](https://github.com/dzangolab/fastify/issues/283)) ([00954dc](https://github.com/dzangolab/fastify/commit/00954dc681f6a8ecce8f483511967bd3b50d05c8)) # [0.26.0](https://github.com/dzangolab/fastify/compare/v0.25.3...v0.26.0) (2023-03-28) - ### Features -* minimize fields in user profile ([#276](https://github.com/dzangolab/fastify/issues/276)) ([5e234c0](https://github.com/dzangolab/fastify/commit/5e234c01f1ae00231fe65445994689508e304b2f)) - - +- minimize fields in user profile ([#276](https://github.com/dzangolab/fastify/issues/276)) ([5e234c0](https://github.com/dzangolab/fastify/commit/5e234c01f1ae00231fe65445994689508e304b2f)) ## [0.25.3](https://github.com/dzangolab/fastify/compare/v0.25.2...v0.25.3) (2023-03-24) - ### Bug Fixes -* update password default option and fixed related tests ([041df05](https://github.com/dzangolab/fastify/commit/041df05dee52e7a355fc2e98842220877f1f1e8e)) - - +- update password default option and fixed related tests ([041df05](https://github.com/dzangolab/fastify/commit/041df05dee52e7a355fc2e98842220877f1f1e8e)) ## [0.25.2](https://github.com/dzangolab/fastify/compare/v0.25.1...v0.25.2) (2023-03-24) - ### Bug Fixes -* add user roles to session on signup ([#274](https://github.com/dzangolab/fastify/issues/274)) ([cc3510c](https://github.com/dzangolab/fastify/commit/cc3510c036608746a7244d9a7240f106464376be)) - - +- add user roles to session on signup ([#274](https://github.com/dzangolab/fastify/issues/274)) ([cc3510c](https://github.com/dzangolab/fastify/commit/cc3510c036608746a7244d9a7240f106464376be)) ## [0.25.1](https://github.com/dzangolab/fastify/compare/v0.25.0...v0.25.1) (2023-03-23) - - # [0.25.0](https://github.com/dzangolab/fastify/compare/v0.24.0...v0.25.0) (2023-03-22) - ### Features -* **user:** Email and Password validation customization with config using zod and validator ([#263](https://github.com/dzangolab/fastify/issues/263)) ([41aa997](https://github.com/dzangolab/fastify/commit/41aa997ea3e075fb2192c684e3b09c0d874a4d69)) - - +- **user:** Email and Password validation customization with config using zod and validator ([#263](https://github.com/dzangolab/fastify/issues/263)) ([41aa997](https://github.com/dzangolab/fastify/commit/41aa997ea3e075fb2192c684e3b09c0d874a4d69)) # [0.24.0](https://github.com/dzangolab/fastify/compare/v0.23.0...v0.24.0) (2023-03-20) ### Features -* **slonik:** upgrade slonik to 33.1.0 ([#259](https://github.com/dzangolab/fastify/issues/260)) ([e1cb147](https://github.com/dzangolab/fastify/commit/e1cb14716f819d2cd3007df2409c947d83842cce)) +- **slonik:** upgrade slonik to 33.1.0 ([#259](https://github.com/dzangolab/fastify/issues/260)) ([e1cb147](https://github.com/dzangolab/fastify/commit/e1cb14716f819d2cd3007df2409c947d83842cce)) # [0.23.0](https://github.com/dzangolab/fastify/compare/v0.22.1...v0.23.0) (2023-03-17) - ### Features -* **mercurius:** upgrade mercurius to 12.0.([#259](https://github.com/dzangolab/fastify/issues/259)) ([b5ae65c](https://github.com/dzangolab/fastify/commit/b5ae65c3203861fce983153f306a91d864a07489)) - - +- **mercurius:** upgrade mercurius to 12.0.([#259](https://github.com/dzangolab/fastify/issues/259)) ([b5ae65c](https://github.com/dzangolab/fastify/commit/b5ae65c3203861fce983153f306a91d864a07489)) ## [0.22.1](https://github.com/dzangolab/fastify/compare/v0.22.0...v0.22.1) (2023-03-09) - ### Bug Fixes -* **multi-tenant:** fix getAllWithAliasesSql method ([#254](https://github.com/dzangolab/fastify/issues/254)) ([5a80a25](https://github.com/dzangolab/fastify/commit/5a80a2546a395c79ab33662cbc0bab2bf0156c9c)) - - +- **multi-tenant:** fix getAllWithAliasesSql method ([#254](https://github.com/dzangolab/fastify/issues/254)) ([5a80a25](https://github.com/dzangolab/fastify/commit/5a80a2546a395c79ab33662cbc0bab2bf0156c9c)) # [0.22.0](https://github.com/dzangolab/fastify/compare/v0.21.0...v0.22.0) (2023-03-08) - ### Features -* add role config for user plugin ([#251](https://github.com/dzangolab/fastify/issues/251)) ([40a5402](https://github.com/dzangolab/fastify/commit/40a540201ff22cd3bc25617f11021cad14918df5)) -* **user:** allow "st-auth-mode" header for auth mode on supertokens ([#243](https://github.com/dzangolab/fastify/issues/243)) ([eecfbae](https://github.com/dzangolab/fastify/commit/eecfbae21fe4051958cbc51fe76bd1629bc2233f)) -* **user:** configurable user table name from config ([#250](https://github.com/dzangolab/fastify/issues/250)) ([45e3faa](https://github.com/dzangolab/fastify/commit/45e3faae9c93391078867bc9c1b7de33427fe415)) - - +- add role config for user plugin ([#251](https://github.com/dzangolab/fastify/issues/251)) ([40a5402](https://github.com/dzangolab/fastify/commit/40a540201ff22cd3bc25617f11021cad14918df5)) +- **user:** allow "st-auth-mode" header for auth mode on supertokens ([#243](https://github.com/dzangolab/fastify/issues/243)) ([eecfbae](https://github.com/dzangolab/fastify/commit/eecfbae21fe4051958cbc51fe76bd1629bc2233f)) +- **user:** configurable user table name from config ([#250](https://github.com/dzangolab/fastify/issues/250)) ([45e3faa](https://github.com/dzangolab/fastify/commit/45e3faae9c93391078867bc9c1b7de33427fe415)) # [0.21.0](https://github.com/dzangolab/fastify/compare/v0.20.0...v0.21.0) (2023-02-23) - ### Features -* add current user route ([#237](https://github.com/dzangolab/fastify/issues/237)) ([aa20d20](https://github.com/dzangolab/fastify/commit/aa20d20ab27493beb40534aab52c9cc06f490ba9)) -* **multi-tenant:** multi-tenant tenant-graphql-context ([#239](https://github.com/dzangolab/fastify/issues/239)) ([551f244](https://github.com/dzangolab/fastify/commit/551f24450c06eaaa5ee69edb11a36789986d3b5e)) - - +- add current user route ([#237](https://github.com/dzangolab/fastify/issues/237)) ([aa20d20](https://github.com/dzangolab/fastify/commit/aa20d20ab27493beb40534aab52c9cc06f490ba9)) +- **multi-tenant:** multi-tenant tenant-graphql-context ([#239](https://github.com/dzangolab/fastify/issues/239)) ([551f244](https://github.com/dzangolab/fastify/commit/551f24450c06eaaa5ee69edb11a36789986d3b5e)) # [0.20.0](https://github.com/dzangolab/fastify/compare/v0.19.0...v0.20.0) (2023-02-21) - ### Features -* skip tenant migration if migration path does not exists([#236](https://github.com/dzangolab/fastify/issues/236)) ([62e2f1a](https://github.com/dzangolab/fastify/commit/62e2f1a7b61001b408575d5346f0766986311895)) - - +- skip tenant migration if migration path does not exists([#236](https://github.com/dzangolab/fastify/issues/236)) ([62e2f1a](https://github.com/dzangolab/fastify/commit/62e2f1a7b61001b408575d5346f0766986311895)) # [0.19.0](https://github.com/dzangolab/fastify/compare/v0.18.3...v0.19.0) (2023-02-17) - ### Features -* add support in buildContext for updating context based on augmentation from other plugins ([#173](https://github.com/dzangolab/fastify/issues/173)) ([5e013d2](https://github.com/dzangolab/fastify/commit/5e013d2c0b16009096035f5d5460dd3972805859)) -* **slonik:** add createDatabase module ([#233](https://github.com/dzangolab/fastify/issues/233)) ([5f30db3](https://github.com/dzangolab/fastify/commit/5f30db3475ab20e0a1aad98ff5ae4647ec50ef5e)) - - +- add support in buildContext for updating context based on augmentation from other plugins ([#173](https://github.com/dzangolab/fastify/issues/173)) ([5e013d2](https://github.com/dzangolab/fastify/commit/5e013d2c0b16009096035f5d5460dd3972805859)) +- **slonik:** add createDatabase module ([#233](https://github.com/dzangolab/fastify/issues/233)) ([5f30db3](https://github.com/dzangolab/fastify/commit/5f30db3475ab20e0a1aad98ff5ae4647ec50ef5e)) ## [0.18.3](https://github.com/dzangolab/fastify/compare/v0.18.2...v0.18.3) (2023-02-16) - - ## [0.18.2](https://github.com/dzangolab/fastify/compare/v0.18.1...v0.18.2) (2023-02-15) ### Bug Fixes -* **multi-tenent:** fix tenant discovery and getFindByHostnameSql ([#230](https://github.com/dzangolab/fastify/issues/230)) ([0aab1bd](https://github.com/dzangolab/fastify/commit/0aab1bd44fa5c4e398437a423cf2dc02b2e904da)) - - +- **multi-tenent:** fix tenant discovery and getFindByHostnameSql ([#230](https://github.com/dzangolab/fastify/issues/230)) ([0aab1bd](https://github.com/dzangolab/fastify/commit/0aab1bd44fa5c4e398437a423cf2dc02b2e904da)) ## [0.18.1](https://github.com/dzangolab/fastify/compare/v0.18.0...v0.18.1) (2023-02-15) - ### Bug Fixes -* **multi-tenent:** fix getAliasedField method in sqlFactory ([#226](https://github.com/dzangolab/fastify/issues/226)) ([5e50ff0](https://github.com/dzangolab/fastify/commit/5e50ff0ac5c8de15487062408193b869f9faac14)) - - +- **multi-tenent:** fix getAliasedField method in sqlFactory ([#226](https://github.com/dzangolab/fastify/issues/226)) ([5e50ff0](https://github.com/dzangolab/fastify/commit/5e50ff0ac5c8de15487062408193b869f9faac14)) # [0.18.0](https://github.com/dzangolab/fastify/compare/v0.17.1...v0.18.0) (2023-02-14) - ### Features -* add tests for mailer plugin ([#222](https://github.com/dzangolab/fastify/issues/222)) ([984543d](https://github.com/dzangolab/fastify/commit/984543d648b62e515c1e3df114685245ed44dbee)) - - +- add tests for mailer plugin ([#222](https://github.com/dzangolab/fastify/issues/222)) ([984543d](https://github.com/dzangolab/fastify/commit/984543d648b62e515c1e3df114685245ed44dbee)) ## [0.17.1](https://github.com/dzangolab/fastify/compare/v0.17.0...v0.17.1) (2023-02-07) - - # [0.17.0](https://github.com/dzangolab/fastify/compare/v0.16.0...v0.17.0) (2023-02-05) - - # [0.16.0](https://github.com/dzangolab/fastify/compare/v0.15.2...v0.16.0) (2023-02-05) - - ## [0.15.2](https://github.com/dzangolab/fastify/compare/v0.15.1...v0.15.2) (2023-02-05) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.50.0 ([#198](https://github.com/dzangolab/fastify/issues/198)) ([7fa5e20](https://github.com/dzangolab/fastify/commit/7fa5e2018f32ba814018046c5630c7d20cfe239f)) - - +- **deps:** update typescript-eslint monorepo to v5.50.0 ([#198](https://github.com/dzangolab/fastify/issues/198)) ([7fa5e20](https://github.com/dzangolab/fastify/commit/7fa5e2018f32ba814018046c5630c7d20cfe239f)) ## [0.15.1](https://github.com/dzangolab/fastify/compare/v0.15.0...v0.15.1) (2023-02-01) - - # [0.15.0](https://github.com/dzangolab/fastify/compare/v0.14.1...v0.15.0) (2023-01-29) - -* Config/tests (#185) ([c924ad9](https://github.com/dzangolab/fastify/commit/c924ad9b1644a4742c3912d395756b1f3dc25a37)), closes [#185](https://github.com/dzangolab/fastify/issues/185) -* Slonik/interceptor/camelize result (#184) ([c42649d](https://github.com/dzangolab/fastify/commit/c42649d55b3900fd9d6b0a92c952f97d65905641)), closes [#184](https://github.com/dzangolab/fastify/issues/184) - +- Config/tests (#185) ([c924ad9](https://github.com/dzangolab/fastify/commit/c924ad9b1644a4742c3912d395756b1f3dc25a37)), closes [#185](https://github.com/dzangolab/fastify/issues/185) +- Slonik/interceptor/camelize result (#184) ([c42649d](https://github.com/dzangolab/fastify/commit/c42649d55b3900fd9d6b0a92c952f97d65905641)), closes [#184](https://github.com/dzangolab/fastify/issues/184) ### BREAKING CHANGES -* SqlFactory arguments have changed. - -* fix(multi-tenant): update service factory - -* chore(slonik): cleanup configuration +- SqlFactory arguments have changed. -* chore(config): cleanup tsconfig -* SqlFactory arguments have changed. +- fix(multi-tenant): update service factory -* fix(multi-tenant): update service factory +- chore(slonik): cleanup configuration -* chore(slonik): cleanup configuration +- chore(config): cleanup tsconfig +- SqlFactory arguments have changed. +- fix(multi-tenant): update service factory +- chore(slonik): cleanup configuration ## [0.14.1](https://github.com/dzangolab/fastify/compare/v0.14.0...v0.14.1) (2023-01-28) - ### Bug Fixes -* **slonik:** fix config.clientConfiguration ([6ae19b5](https://github.com/dzangolab/fastify/commit/6ae19b5adabc1f3fe34051137ae16db04e5a3ae7)) - - +- **slonik:** fix config.clientConfiguration ([6ae19b5](https://github.com/dzangolab/fastify/commit/6ae19b5adabc1f3fe34051137ae16db04e5a3ae7)) # [0.14.0](https://github.com/dzangolab/fastify/compare/v0.13.0...v0.14.0) (2023-01-28) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.1 ([#157](https://github.com/dzangolab/fastify/issues/157)) ([79b981d](https://github.com/dzangolab/fastify/commit/79b981d55ad0ddf329d4e6725b14141193e975b9)) -* **deps:** update dependency nodemailer-mjml to v1.2.4 ([#172](https://github.com/dzangolab/fastify/issues/172)) ([4ae3d0f](https://github.com/dzangolab/fastify/commit/4ae3d0fab6550587001c252038a8d959ccec3f4b)) -* **multi-tenant:** slonik.migrations may be undefined ([7df67e5](https://github.com/dzangolab/fastify/commit/7df67e502f52de887f0ebd21112b60745ef55f2e)) - +- **deps:** update dependency nodemailer to v6.9.1 ([#157](https://github.com/dzangolab/fastify/issues/157)) ([79b981d](https://github.com/dzangolab/fastify/commit/79b981d55ad0ddf329d4e6725b14141193e975b9)) +- **deps:** update dependency nodemailer-mjml to v1.2.4 ([#172](https://github.com/dzangolab/fastify/issues/172)) ([4ae3d0f](https://github.com/dzangolab/fastify/commit/4ae3d0fab6550587001c252038a8d959ccec3f4b)) +- **multi-tenant:** slonik.migrations may be undefined ([7df67e5](https://github.com/dzangolab/fastify/commit/7df67e502f52de887f0ebd21112b60745ef55f2e)) ### Features -* **slonik:** add default migrations path "migrations" ([#179](https://github.com/dzangolab/fastify/issues/179)) ([7f67036](https://github.com/dzangolab/fastify/commit/7f67036d9b2f89307ec8c4615ed920d06fe4cec1)) - - +- **slonik:** add default migrations path "migrations" ([#179](https://github.com/dzangolab/fastify/issues/179)) ([7f67036](https://github.com/dzangolab/fastify/commit/7f67036d9b2f89307ec8c4615ed920d06fe4cec1)) # [0.14.0](https://github.com/dzangolab/fastify/compare/v0.13.0...v0.14.0) (2023-01-28) - ### Bug Fixes -* **deps:** update dependency nodemailer to v6.9.1 ([#157](https://github.com/dzangolab/fastify/issues/157)) ([79b981d](https://github.com/dzangolab/fastify/commit/79b981d55ad0ddf329d4e6725b14141193e975b9)) -* **deps:** update dependency nodemailer-mjml to v1.2.4 ([#172](https://github.com/dzangolab/fastify/issues/172)) ([4ae3d0f](https://github.com/dzangolab/fastify/commit/4ae3d0fab6550587001c252038a8d959ccec3f4b)) - +- **deps:** update dependency nodemailer to v6.9.1 ([#157](https://github.com/dzangolab/fastify/issues/157)) ([79b981d](https://github.com/dzangolab/fastify/commit/79b981d55ad0ddf329d4e6725b14141193e975b9)) +- **deps:** update dependency nodemailer-mjml to v1.2.4 ([#172](https://github.com/dzangolab/fastify/issues/172)) ([4ae3d0f](https://github.com/dzangolab/fastify/commit/4ae3d0fab6550587001c252038a8d959ccec3f4b)) ### Features -* **slonik:** add default migrations path "migrations" ([#179](https://github.com/dzangolab/fastify/issues/179)) ([7f67036](https://github.com/dzangolab/fastify/commit/7f67036d9b2f89307ec8c4615ed920d06fe4cec1)) - - +- **slonik:** add default migrations path "migrations" ([#179](https://github.com/dzangolab/fastify/issues/179)) ([7f67036](https://github.com/dzangolab/fastify/commit/7f67036d9b2f89307ec8c4615ed920d06fe4cec1)) # [0.13.0](https://github.com/dzangolab/fastify/compare/v0.12.3...v0.13.0) (2023-01-26) - ### Bug Fixes -* **deps:** update dependency eslint-plugin-import to v2.27.5 ([#167](https://github.com/dzangolab/fastify/issues/167)) ([b010b3a](https://github.com/dzangolab/fastify/commit/b010b3a62b963462fff578d52f5a5e6ac7e8d227)) -* **deps:** update dependency nodemailer-mjml to v1.2.3 ([#168](https://github.com/dzangolab/fastify/issues/168)) ([93c7614](https://github.com/dzangolab/fastify/commit/93c7614794caca9e9d14d06e161abd5fee96fd55)) -* **deps:** update typescript-eslint monorepo to v5.49.0 ([#161](https://github.com/dzangolab/fastify/issues/161)) ([7cef927](https://github.com/dzangolab/fastify/commit/7cef927e36f4635aa7241549ecf9486687e673bc)) - +- **deps:** update dependency eslint-plugin-import to v2.27.5 ([#167](https://github.com/dzangolab/fastify/issues/167)) ([b010b3a](https://github.com/dzangolab/fastify/commit/b010b3a62b963462fff578d52f5a5e6ac7e8d227)) +- **deps:** update dependency nodemailer-mjml to v1.2.3 ([#168](https://github.com/dzangolab/fastify/issues/168)) ([93c7614](https://github.com/dzangolab/fastify/commit/93c7614794caca9e9d14d06e161abd5fee96fd55)) +- **deps:** update typescript-eslint monorepo to v5.49.0 ([#161](https://github.com/dzangolab/fastify/issues/161)) ([7cef927](https://github.com/dzangolab/fastify/commit/7cef927e36f4635aa7241549ecf9486687e673bc)) ### Features -* **slonik:** schema support for sql queries ([#148](https://github.com/dzangolab/fastify/issues/148)) ([67b52fd](https://github.com/dzangolab/fastify/commit/67b52fd3ba3cf10f9c83746baf755645abd7b219)) - - +- **slonik:** schema support for sql queries ([#148](https://github.com/dzangolab/fastify/issues/148)) ([67b52fd](https://github.com/dzangolab/fastify/commit/67b52fd3ba3cf10f9c83746baf755645abd7b219)) ## [0.12.3](https://github.com/dzangolab/fastify/compare/v0.12.2...v0.12.3) (2023-01-18) - ### Bug Fixes -* **slonik:** fix code style ([e7bc58c](https://github.com/dzangolab/fastify/commit/e7bc58c1f493d720042a1df938e7125995b3f989)) -* **slonik:** fix code style ([9a7d792](https://github.com/dzangolab/fastify/commit/9a7d792e68543c6ecca1337e1eeb5e4809306618)) - - +- **slonik:** fix code style ([e7bc58c](https://github.com/dzangolab/fastify/commit/e7bc58c1f493d720042a1df938e7125995b3f989)) +- **slonik:** fix code style ([9a7d792](https://github.com/dzangolab/fastify/commit/9a7d792e68543c6ecca1337e1eeb5e4809306618)) ## [0.12.2](https://github.com/dzangolab/fastify/compare/v0.12.1...v0.12.2) (2023-01-13) - ### Bug Fixes -* **deps:** update dependency eslint-import-resolver-typescript to v3.5.3 ([#147](https://github.com/dzangolab/fastify/issues/147)) ([7cf223a](https://github.com/dzangolab/fastify/commit/7cf223a41c31d16bdd711ea4d87f6bb1bb4d793e)) -* **deps:** update dependency eslint-plugin-import to v2.27.4 ([#154](https://github.com/dzangolab/fastify/issues/154)) ([6f57d1f](https://github.com/dzangolab/fastify/commit/6f57d1f75233c2ddb12cefdc70e5477fc4685132)) -* **deps:** update typescript-eslint monorepo to v5.48.1 ([#143](https://github.com/dzangolab/fastify/issues/143)) ([44dbbf7](https://github.com/dzangolab/fastify/commit/44dbbf737d4380d5ee64e5d582507646384b570a)) -* **slonik:** make minor fixes to slonik package ([#153](https://github.com/dzangolab/fastify/issues/153)) ([1384df6](https://github.com/dzangolab/fastify/commit/1384df6727c5367a4d9b6205252a749df8ff5aba)) - - +- **deps:** update dependency eslint-import-resolver-typescript to v3.5.3 ([#147](https://github.com/dzangolab/fastify/issues/147)) ([7cf223a](https://github.com/dzangolab/fastify/commit/7cf223a41c31d16bdd711ea4d87f6bb1bb4d793e)) +- **deps:** update dependency eslint-plugin-import to v2.27.4 ([#154](https://github.com/dzangolab/fastify/issues/154)) ([6f57d1f](https://github.com/dzangolab/fastify/commit/6f57d1f75233c2ddb12cefdc70e5477fc4685132)) +- **deps:** update typescript-eslint monorepo to v5.48.1 ([#143](https://github.com/dzangolab/fastify/issues/143)) ([44dbbf7](https://github.com/dzangolab/fastify/commit/44dbbf737d4380d5ee64e5d582507646384b570a)) +- **slonik:** make minor fixes to slonik package ([#153](https://github.com/dzangolab/fastify/issues/153)) ([1384df6](https://github.com/dzangolab/fastify/commit/1384df6727c5367a4d9b6205252a749df8ff5aba)) ## [0.12.1](https://github.com/dzangolab/fastify/compare/v0.12.0...v0.12.1) (2023-01-12) - ### Bug Fixes -* **deps:** update dependency eslint-config-prettier to v8.6.0 ([#131](https://github.com/dzangolab/fastify/issues/131)) ([dcf9ea5](https://github.com/dzangolab/fastify/commit/dcf9ea571e6bf9f49834afeb69a7c9ccafa7a995)) -* **deps:** update dependency nodemailer-mjml to v1.2.2 ([#138](https://github.com/dzangolab/fastify/issues/138)) ([338379c](https://github.com/dzangolab/fastify/commit/338379cac35d137db7c1334c67397b9db4ebb09f)) -* **deps:** update typescript-eslint monorepo to v5.48.0 ([#130](https://github.com/dzangolab/fastify/issues/130)) ([6c4ee5d](https://github.com/dzangolab/fastify/commit/6c4ee5d17cf47a7b3570b747429f311cc5eeff35)) - +- **deps:** update dependency eslint-config-prettier to v8.6.0 ([#131](https://github.com/dzangolab/fastify/issues/131)) ([dcf9ea5](https://github.com/dzangolab/fastify/commit/dcf9ea571e6bf9f49834afeb69a7c9ccafa7a995)) +- **deps:** update dependency nodemailer-mjml to v1.2.2 ([#138](https://github.com/dzangolab/fastify/issues/138)) ([338379c](https://github.com/dzangolab/fastify/commit/338379cac35d137db7c1334c67397b9db4ebb09f)) +- **deps:** update typescript-eslint monorepo to v5.48.0 ([#130](https://github.com/dzangolab/fastify/issues/130)) ([6c4ee5d](https://github.com/dzangolab/fastify/commit/6c4ee5d17cf47a7b3570b747429f311cc5eeff35)) ### Performance Improvements -* **fastify-mailer:** Add support for template data from config ([#135](https://github.com/dzangolab/fastify/issues/135)) ([1b442d0](https://github.com/dzangolab/fastify/commit/1b442d0834fca2df097b4ad836e0abbf4a0914a5)) - - +- **fastify-mailer:** Add support for template data from config ([#135](https://github.com/dzangolab/fastify/issues/135)) ([1b442d0](https://github.com/dzangolab/fastify/commit/1b442d0834fca2df097b4ad836e0abbf4a0914a5)) ## [0.12.2](https://github.com/dzangolab/fastify/compare/v0.12.1...v0.12.2) (2023-01-13) - ### Bug Fixes -* **deps:** update dependency eslint-import-resolver-typescript to v3.5.3 ([#147](https://github.com/dzangolab/fastify/issues/147)) ([7cf223a](https://github.com/dzangolab/fastify/commit/7cf223a41c31d16bdd711ea4d87f6bb1bb4d793e)) -* **deps:** update dependency eslint-plugin-import to v2.27.4 ([#154](https://github.com/dzangolab/fastify/issues/154)) ([6f57d1f](https://github.com/dzangolab/fastify/commit/6f57d1f75233c2ddb12cefdc70e5477fc4685132)) -* **deps:** update typescript-eslint monorepo to v5.48.1 ([#143](https://github.com/dzangolab/fastify/issues/143)) ([44dbbf7](https://github.com/dzangolab/fastify/commit/44dbbf737d4380d5ee64e5d582507646384b570a)) -* **slonik:** make minor fixes to slonik package ([#153](https://github.com/dzangolab/fastify/issues/153)) ([1384df6](https://github.com/dzangolab/fastify/commit/1384df6727c5367a4d9b6205252a749df8ff5aba)) - - +- **deps:** update dependency eslint-import-resolver-typescript to v3.5.3 ([#147](https://github.com/dzangolab/fastify/issues/147)) ([7cf223a](https://github.com/dzangolab/fastify/commit/7cf223a41c31d16bdd711ea4d87f6bb1bb4d793e)) +- **deps:** update dependency eslint-plugin-import to v2.27.4 ([#154](https://github.com/dzangolab/fastify/issues/154)) ([6f57d1f](https://github.com/dzangolab/fastify/commit/6f57d1f75233c2ddb12cefdc70e5477fc4685132)) +- **deps:** update typescript-eslint monorepo to v5.48.1 ([#143](https://github.com/dzangolab/fastify/issues/143)) ([44dbbf7](https://github.com/dzangolab/fastify/commit/44dbbf737d4380d5ee64e5d582507646384b570a)) +- **slonik:** make minor fixes to slonik package ([#153](https://github.com/dzangolab/fastify/issues/153)) ([1384df6](https://github.com/dzangolab/fastify/commit/1384df6727c5367a4d9b6205252a749df8ff5aba)) ## [0.12.1](https://github.com/dzangolab/fastify/compare/v0.12.0...v0.12.1) (2023-01-12) - ### Bug Fixes -* **deps:** update dependency eslint-config-prettier to v8.6.0 ([#131](https://github.com/dzangolab/fastify/issues/131)) ([dcf9ea5](https://github.com/dzangolab/fastify/commit/dcf9ea571e6bf9f49834afeb69a7c9ccafa7a995)) -* **deps:** update dependency nodemailer-mjml to v1.2.2 ([#138](https://github.com/dzangolab/fastify/issues/138)) ([338379c](https://github.com/dzangolab/fastify/commit/338379cac35d137db7c1334c67397b9db4ebb09f)) -* **deps:** update typescript-eslint monorepo to v5.48.0 ([#130](https://github.com/dzangolab/fastify/issues/130)) ([6c4ee5d](https://github.com/dzangolab/fastify/commit/6c4ee5d17cf47a7b3570b747429f311cc5eeff35)) - +- **deps:** update dependency eslint-config-prettier to v8.6.0 ([#131](https://github.com/dzangolab/fastify/issues/131)) ([dcf9ea5](https://github.com/dzangolab/fastify/commit/dcf9ea571e6bf9f49834afeb69a7c9ccafa7a995)) +- **deps:** update dependency nodemailer-mjml to v1.2.2 ([#138](https://github.com/dzangolab/fastify/issues/138)) ([338379c](https://github.com/dzangolab/fastify/commit/338379cac35d137db7c1334c67397b9db4ebb09f)) +- **deps:** update typescript-eslint monorepo to v5.48.0 ([#130](https://github.com/dzangolab/fastify/issues/130)) ([6c4ee5d](https://github.com/dzangolab/fastify/commit/6c4ee5d17cf47a7b3570b747429f311cc5eeff35)) ### Performance Improvements -* **fastify-mailer:** Add support for template data from config ([#135](https://github.com/dzangolab/fastify/issues/135)) ([1b442d0](https://github.com/dzangolab/fastify/commit/1b442d0834fca2df097b4ad836e0abbf4a0914a5)) - - +- **fastify-mailer:** Add support for template data from config ([#135](https://github.com/dzangolab/fastify/issues/135)) ([1b442d0](https://github.com/dzangolab/fastify/commit/1b442d0834fca2df097b4ad836e0abbf4a0914a5)) # [0.12.0](https://github.com/dzangolab/fastify/compare/v0.11.2...v0.12.0) (2022-12-27) - ### Features -* add filter and sort on slonik ([#114](https://github.com/dzangolab/fastify/issues/114)) ([7c8b7a6](https://github.com/dzangolab/fastify/commit/7c8b7a647d4192339deaf770c834b55eafdbc133)), closes [#119](https://github.com/dzangolab/fastify/issues/119) - - +- add filter and sort on slonik ([#114](https://github.com/dzangolab/fastify/issues/114)) ([7c8b7a6](https://github.com/dzangolab/fastify/commit/7c8b7a647d4192339deaf770c834b55eafdbc133)), closes [#119](https://github.com/dzangolab/fastify/issues/119) ## [0.11.2](https://github.com/dzangolab/fastify/compare/v0.11.1...v0.11.2) (2022-12-27) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.47.1 ([#123](https://github.com/dzangolab/fastify/issues/123)) ([3364c35](https://github.com/dzangolab/fastify/commit/3364c35cad8163af3ce7f357779da0f8462fec6e)) - - +- **deps:** update typescript-eslint monorepo to v5.47.1 ([#123](https://github.com/dzangolab/fastify/issues/123)) ([3364c35](https://github.com/dzangolab/fastify/commit/3364c35cad8163af3ce7f357779da0f8462fec6e)) ## [0.11.1](https://github.com/dzangolab/fastify/compare/v0.11.0...v0.11.1) (2022-12-25) - - # [0.11.0](https://github.com/dzangolab/fastify/compare/v0.10.8...v0.11.0) (2022-12-21) - ### Features -* **slonik:** change slonik.migrations config type ([#115](https://github.com/dzangolab/fastify/issues/115)) ([f8b0abf](https://github.com/dzangolab/fastify/commit/f8b0abf4190efbaf168efe275e042810483ee18e)) - - +- **slonik:** change slonik.migrations config type ([#115](https://github.com/dzangolab/fastify/issues/115)) ([f8b0abf](https://github.com/dzangolab/fastify/commit/f8b0abf4190efbaf168efe275e042810483ee18e)) ## [0.10.8](https://github.com/dzangolab/fastify/compare/v0.10.7...v0.10.8) (2022-12-20) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.47.0 ([#112](https://github.com/dzangolab/fastify/issues/112)) ([acb039f](https://github.com/dzangolab/fastify/commit/acb039f53822ddcffc14c70ea786078984c762bf)) - - +- **deps:** update typescript-eslint monorepo to v5.47.0 ([#112](https://github.com/dzangolab/fastify/issues/112)) ([acb039f](https://github.com/dzangolab/fastify/commit/acb039f53822ddcffc14c70ea786078984c762bf)) ## [0.10.7](https://github.com/dzangolab/fastify/compare/v0.10.6...v0.10.7) (2022-12-18) - - ## [0.10.6](https://github.com/dzangolab/fastify/compare/v0.10.5...v0.10.6) (2022-12-18) - - ## [0.10.5](https://github.com/dzangolab/fastify/compare/v0.10.4...v0.10.5) (2022-12-18) - - ## [0.10.4](https://github.com/dzangolab/fastify/compare/v0.10.3...v0.10.4) (2022-12-18) - - ## [0.10.3](https://github.com/dzangolab/fastify/compare/v0.10.2...v0.10.3) (2022-12-18) - - ## [0.10.2](https://github.com/dzangolab/fastify/compare/v0.10.1...v0.10.2) (2022-12-18) - - ## [0.10.1](https://github.com/dzangolab/fastify/compare/v0.10.0...v0.10.1) (2022-12-18) - - # [0.10.0](https://github.com/dzangolab/fastify/compare/v0.9.2...v0.10.0) (2022-12-18) - ### Features -* **mailer:** add mjml and other plugins to nodemailer ([#101](https://github.com/dzangolab/fastify/issues/101)) ([b0fc6a2](https://github.com/dzangolab/fastify/commit/b0fc6a2af9967147b0465e2d24bf485c409b01df)) - - +- **mailer:** add mjml and other plugins to nodemailer ([#101](https://github.com/dzangolab/fastify/issues/101)) ([b0fc6a2](https://github.com/dzangolab/fastify/commit/b0fc6a2af9967147b0465e2d24bf485c409b01df)) ## [0.9.2](https://github.com/dzangolab/fastify/compare/v0.9.1...v0.9.2) (2022-12-18) - - ## [0.9.1](https://github.com/dzangolab/fastify/compare/v0.9.0...v0.9.1) (2022-12-17) - - # [0.9.0](https://github.com/dzangolab/fastify/compare/v0.8.6...v0.9.0) (2022-12-17) - - ## [0.8.6](https://github.com/dzangolab/fastify/compare/v0.8.5...v0.8.6) (2022-12-17) - ### Bug Fixes -* **deps:** update dependency eslint-plugin-unicorn to v45.0.2 ([#87](https://github.com/dzangolab/fastify/issues/87)) ([e146ad8](https://github.com/dzangolab/fastify/commit/e146ad8bb4a35cf1f90637e9e1c2743425e27426)) -* **deps:** update typescript-eslint monorepo to v5.46.1 ([#75](https://github.com/dzangolab/fastify/issues/75)) ([3573401](https://github.com/dzangolab/fastify/commit/35734018cc443efcdfb7e6ded775393285ff4160)) - - +- **deps:** update dependency eslint-plugin-unicorn to v45.0.2 ([#87](https://github.com/dzangolab/fastify/issues/87)) ([e146ad8](https://github.com/dzangolab/fastify/commit/e146ad8bb4a35cf1f90637e9e1c2743425e27426)) +- **deps:** update typescript-eslint monorepo to v5.46.1 ([#75](https://github.com/dzangolab/fastify/issues/75)) ([3573401](https://github.com/dzangolab/fastify/commit/35734018cc443efcdfb7e6ded775393285ff4160)) ## [0.8.5](https://github.com/dzangolab/fastify/compare/v0.8.4...v0.8.5) (2022-12-11) - - ## [0.8.4](https://github.com/dzangolab/fastify/compare/v0.8.3...v0.8.4) (2022-12-11) - - ## [0.8.3](https://github.com/dzangolab/fastify/compare/v0.8.2...v0.8.3) (2022-12-10) - - ## [0.8.2](https://github.com/dzangolab/fastify/compare/v0.8.1...v0.8.2) (2022-12-10) - - ## [0.8.1](https://github.com/dzangolab/fastify/compare/v0.7.0...v0.8.1) (2022-12-10) - - # [0.8.0](https://github.com/dzangolab/fastify/compare/v0.7.0...v0.8.0) (2022-12-10) - ### Features -* **mercurius:** add fastiify-mercurius plugin ([30aeb19](https://github.com/dzangolab/fastify/commit/30aeb19d2c97a5c7a6af4a15d276c62f4d8fce8a)) - - +- **mercurius:** add fastiify-mercurius plugin ([30aeb19](https://github.com/dzangolab/fastify/commit/30aeb19d2c97a5c7a6af4a15d276c62f4d8fce8a)) # [0.8.0](https://github.com/dzangolab/fastify/compare/v0.7.0...v0.8.0) (2022-12-10) - ### Features -* **mercurius:** add fastiify-mercurius plugin ([30aeb19](https://github.com/dzangolab/fastify/commit/30aeb19d2c97a5c7a6af4a15d276c62f4d8fce8a)) - - +- **mercurius:** add fastiify-mercurius plugin ([30aeb19](https://github.com/dzangolab/fastify/commit/30aeb19d2c97a5c7a6af4a15d276c62f4d8fce8a)) # [0.7.0](https://github.com/dzangolab/fastify/compare/v0.6.1...v0.7.0) (2022-12-10) - ### Features -* **config:** remove supertokens attribute ([ab65d71](https://github.com/dzangolab/fastify/commit/ab65d71bcbc961b0e9bdd84a3046659d35f1c0db)) - - +- **config:** remove supertokens attribute ([ab65d71](https://github.com/dzangolab/fastify/commit/ab65d71bcbc961b0e9bdd84a3046659d35f1c0db)) ## [0.6.1](https://github.com/dzangolab/fastify/compare/v0.6.0...v0.6.1) (2022-12-10) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.46.0 ([#72](https://github.com/dzangolab/fastify/issues/72)) ([d6090cf](https://github.com/dzangolab/fastify/commit/d6090cfc72a9f2a48d83979eb5c845e144918aee)) - - +- **deps:** update typescript-eslint monorepo to v5.46.0 ([#72](https://github.com/dzangolab/fastify/issues/72)) ([d6090cf](https://github.com/dzangolab/fastify/commit/d6090cfc72a9f2a48d83979eb5c845e144918aee)) # [0.6.0](https://github.com/dzangolab/fastify/compare/v0.5.10...v0.6.0) (2022-12-08) - ### Features -* **config:** deprecate graphql and graphiql attributes from config ([1710a45](https://github.com/dzangolab/fastify/commit/1710a45a04e0e7e610d59ea38dce887de3d0006a)) - - +- **config:** deprecate graphql and graphiql attributes from config ([1710a45](https://github.com/dzangolab/fastify/commit/1710a45a04e0e7e610d59ea38dce887de3d0006a)) ## [0.5.10](https://github.com/dzangolab/fastify/compare/v0.5.9...v0.5.10) (2022-12-07) - ### Bug Fixes -* **deps:** update typescript-eslint monorepo to v5.45.1 ([#60](https://github.com/dzangolab/fastify/issues/60)) ([1794046](https://github.com/dzangolab/fastify/commit/1794046a473ad5ef64f0b2e0d85ddfe3064d0fdd)) - - +- **deps:** update typescript-eslint monorepo to v5.45.1 ([#60](https://github.com/dzangolab/fastify/issues/60)) ([1794046](https://github.com/dzangolab/fastify/commit/1794046a473ad5ef64f0b2e0d85ddfe3064d0fdd)) ## [0.5.9](https://github.com/dzangolab/fastify/compare/v0.5.8...v0.5.9) (2022-12-07) - ### Bug Fixes -* **slonik:** exclude postgres-migrations from build ([9c62397](https://github.com/dzangolab/fastify/commit/9c623976af227a0c49f54185154ad7db97799edb)) - - +- **slonik:** exclude postgres-migrations from build ([9c62397](https://github.com/dzangolab/fastify/commit/9c623976af227a0c49f54185154ad7db97799edb)) ## [0.5.8](https://github.com/dzangolab/fastify/compare/v0.5.7...v0.5.8) (2022-12-07) - ### Bug Fixes -* **slonik:** make postgres-migrations a peer dependency ([ea6fd38](https://github.com/dzangolab/fastify/commit/ea6fd38e802971b21a02c509f2f012d381f635cd)) - - +- **slonik:** make postgres-migrations a peer dependency ([ea6fd38](https://github.com/dzangolab/fastify/commit/ea6fd38e802971b21a02c509f2f012d381f635cd)) ## [0.5.7](https://github.com/dzangolab/fastify/compare/v0.5.6...v0.5.7) (2022-12-07) - - ## [0.5.6](https://github.com/dzangolab/fastify/compare/v0.5.5...v0.5.6) (2022-12-07) - - ## [0.5.5](https://github.com/dzangolab/fastify/compare/v0.5.4...v0.5.5) (2022-12-07) - ### Bug Fixes -* **slonik:** fix migrations path ([cbef31a](https://github.com/dzangolab/fastify/commit/cbef31a271f1b21e3f390e1bd811c2ca60c0ac57)) - - +- **slonik:** fix migrations path ([cbef31a](https://github.com/dzangolab/fastify/commit/cbef31a271f1b21e3f390e1bd811c2ca60c0ac57)) ## [0.5.4](https://github.com/dzangolab/fastify/compare/v0.5.3...v0.5.4) (2022-12-06) - ### Bug Fixes -* **slonik:** make fastify-slonik a peer dependency ([ff607ab](https://github.com/dzangolab/fastify/commit/ff607abd34c83ba21a5adf658c958d5284f18903)) -* **slonik:** update dependencies ([dd97082](https://github.com/dzangolab/fastify/commit/dd970829a0641179b0ec27f02ed54c3d98fef5f7)) - - +- **slonik:** make fastify-slonik a peer dependency ([ff607ab](https://github.com/dzangolab/fastify/commit/ff607abd34c83ba21a5adf658c958d5284f18903)) +- **slonik:** update dependencies ([dd97082](https://github.com/dzangolab/fastify/commit/dd970829a0641179b0ec27f02ed54c3d98fef5f7)) ## [0.5.3](https://github.com/dzangolab/fastify/compare/v0.5.2...v0.5.3) (2022-12-04) - ### Bug Fixes -* **slonik:** make postgres-migrations a peer dependency ([a720be0](https://github.com/dzangolab/fastify/commit/a720be0ddc82de670717cad182a749be1213b233)) - - +- **slonik:** make postgres-migrations a peer dependency ([a720be0](https://github.com/dzangolab/fastify/commit/a720be0ddc82de670717cad182a749be1213b233)) ## [0.5.2](https://github.com/dzangolab/fastify/compare/v0.5.1...v0.5.2) (2022-12-04) - ### Bug Fixes -* **slonik:** augment fastify types ([fc3cb75](https://github.com/dzangolab/fastify/commit/fc3cb759fbe3cd28557e0d25800a76b0d0b76e5c)) - - +- **slonik:** augment fastify types ([fc3cb75](https://github.com/dzangolab/fastify/commit/fc3cb759fbe3cd28557e0d25800a76b0d0b76e5c)) ## [0.5.1](https://github.com/dzangolab/fastify/compare/v0.3.2...v0.5.1) (2022-12-04) - - # [0.5.0](https://github.com/dzangolab/fastify/compare/v0.4.0...v0.5.0) (2022-12-03) - ### Features -* **slonik:** add fastify-slonik plugin ([#43](https://github.com/dzangolab/fastify/issues/43)) ([2da5b09](https://github.com/dzangolab/fastify/commit/2da5b09dfc1b67b802c22b573e2e1d9208586c4e)) - - +- **slonik:** add fastify-slonik plugin ([#43](https://github.com/dzangolab/fastify/issues/43)) ([2da5b09](https://github.com/dzangolab/fastify/commit/2da5b09dfc1b67b802c22b573e2e1d9208586c4e)) # [0.4.0](https://github.com/dzangolab/fastify/compare/v0.3.0...v0.4.0) (2022-12-03) - ### Features -* **config:** remove `db` attribute from ApiConfig ([#41](https://github.com/dzangolab/fastify/issues/41)) ([9b1ec37](https://github.com/dzangolab/fastify/commit/9b1ec375b72b166035625f1aa3be9b6581e19e88)) - - +- **config:** remove `db` attribute from ApiConfig ([#41](https://github.com/dzangolab/fastify/issues/41)) ([9b1ec37](https://github.com/dzangolab/fastify/commit/9b1ec375b72b166035625f1aa3be9b6581e19e88)) ## [0.3.2](https://github.com/dzangolab/fastify/compare/v0.3.1...v0.3.2) (2022-12-04) - ### Bug Fixes -* **config:** extract plugin as separate file ([#52](https://github.com/dzangolab/fastify/issues/52)) ([2685ae9](https://github.com/dzangolab/fastify/commit/2685ae96eecc2f1b8e907f2bd432db43b2404344)) - - +- **config:** extract plugin as separate file ([#52](https://github.com/dzangolab/fastify/issues/52)) ([2685ae9](https://github.com/dzangolab/fastify/commit/2685ae96eecc2f1b8e907f2bd432db43b2404344)) ## [0.3.1](https://github.com/dzangolab/fastify/compare/v0.2.1...v0.3.1) (2022-12-04) - - # [0.3.0](https://github.com/dzangolab/fastify/compare/v0.2.0...v0.3.0) (2022-12-03) - - ## [0.2.1](https://github.com/dzangolab/fastify/compare/v0.5.0...v0.2.1) (2022-12-04) - ### Bug Fixes -* **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) - - +- **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) # [0.5.0](https://github.com/dzangolab/fastify/compare/v0.4.0...v0.5.0) (2022-12-03) - ### Features -* **slonik:** add fastify-slonik plugin ([#43](https://github.com/dzangolab/fastify/issues/43)) ([2da5b09](https://github.com/dzangolab/fastify/commit/2da5b09dfc1b67b802c22b573e2e1d9208586c4e)) - - +- **slonik:** add fastify-slonik plugin ([#43](https://github.com/dzangolab/fastify/issues/43)) ([2da5b09](https://github.com/dzangolab/fastify/commit/2da5b09dfc1b67b802c22b573e2e1d9208586c4e)) # [0.4.0](https://github.com/dzangolab/fastify/compare/v0.3.0...v0.4.0) (2022-12-03) - ### Features -* **config:** remove `db` attribute from ApiConfig ([#41](https://github.com/dzangolab/fastify/issues/41)) ([9b1ec37](https://github.com/dzangolab/fastify/commit/9b1ec375b72b166035625f1aa3be9b6581e19e88)) - +- **config:** remove `db` attribute from ApiConfig ([#41](https://github.com/dzangolab/fastify/issues/41)) ([9b1ec37](https://github.com/dzangolab/fastify/commit/9b1ec375b72b166035625f1aa3be9b6581e19e88)) ## [0.3.3](https://github.com/dzangolab/fastify/compare/v0.3.2...v0.3.3) (2022-12-04) - - ### Bug Fixes -* **config:** extract plugin as separate file ([#52](https://github.com/dzangolab/fastify/issues/52)) ([2685ae9](https://github.com/dzangolab/fastify/commit/2685ae96eecc2f1b8e907f2bd432db43b2404344)) - - +- **config:** extract plugin as separate file ([#52](https://github.com/dzangolab/fastify/issues/52)) ([2685ae9](https://github.com/dzangolab/fastify/commit/2685ae96eecc2f1b8e907f2bd432db43b2404344)) ## [0.3.1](https://github.com/dzangolab/fastify/compare/v0.3.0...v0.3.1) (2022-12-04) - ### Bug Fixes -* **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) - - +- **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) # [0.3.0](https://github.com/dzangolab/fastify/compare/v0.2.0...v0.3.0) (2022-12-03) ### Features -* **config:** add parse function ([#39](https://github.com/dzangolab/fastify/issues/39)) ([907d8b4b](https://github.com/dzangolab/fastify/commit/907d84b013559064df2205d3f0f3956398c4b37b)) - -* **config:** add parse function ([#38](https://github.com/dzangolab/fastify/issues/38)) ([a56a50ee](https://github.com/dzangolab/fastify/commit/a56a50ee01d96011916677a01e648980b02ec2b3)) +- **config:** add parse function ([#39](https://github.com/dzangolab/fastify/issues/39)) ([907d8b4b](https://github.com/dzangolab/fastify/commit/907d84b013559064df2205d3f0f3956398c4b37b)) +- **config:** add parse function ([#38](https://github.com/dzangolab/fastify/issues/38)) ([a56a50ee](https://github.com/dzangolab/fastify/commit/a56a50ee01d96011916677a01e648980b02ec2b3)) ## [0.2.1](https://github.com/dzangolab/fastify/compare/v0.2.0...v0.2.1) (2022-12-04) - ### Bug Fixes -* **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) - - +- **config:** fix export of ApiConfig type ([39ec736](https://github.com/dzangolab/fastify/commit/39ec73655d0fab488b33a8e8b9365d58b100dd9b)) # [0.2.0](https://github.com/dzangolab/fastify/compare/v0.1.0...v0.2.0) (2022-12-02) - ### Features -* **config:** remove logLevel attribute ([#35](https://github.com/dzangolab/fastify/issues/35)) ([6070617](https://github.com/dzangolab/fastify/commit/6070617fea8e235cfcdb974d6826490f9f7b62a5)) - - +- **config:** remove logLevel attribute ([#35](https://github.com/dzangolab/fastify/issues/35)) ([6070617](https://github.com/dzangolab/fastify/commit/6070617fea8e235cfcdb974d6826490f9f7b62a5)) # [0.1.0](https://github.com/dzangolab/fastify/compare/v0.0.14...v0.1.0) (2022-12-02) - ### Bug Fixes -* **deps:** update dependency eslint-config-turbo to v0.0.7 ([#32](https://github.com/dzangolab/fastify/issues/32)) ([cba3607](https://github.com/dzangolab/fastify/commit/cba360747ddea0258c3a910569d1a9b5d8dc07f2)) -* **deps:** update dependency eslint-plugin-unicorn to v45.0.1 ([#29](https://github.com/dzangolab/fastify/issues/29)) ([1216519](https://github.com/dzangolab/fastify/commit/1216519ae00866b58ed5037cbedad04fd15a43cc)) -* **deps:** update typescript-eslint monorepo to v5.45.0 ([#30](https://github.com/dzangolab/fastify/issues/30)) ([0b41dc0](https://github.com/dzangolab/fastify/commit/0b41dc0299e1d4660fe46470fa2decf7033d98f2)) - - +- **deps:** update dependency eslint-config-turbo to v0.0.7 ([#32](https://github.com/dzangolab/fastify/issues/32)) ([cba3607](https://github.com/dzangolab/fastify/commit/cba360747ddea0258c3a910569d1a9b5d8dc07f2)) +- **deps:** update dependency eslint-plugin-unicorn to v45.0.1 ([#29](https://github.com/dzangolab/fastify/issues/29)) ([1216519](https://github.com/dzangolab/fastify/commit/1216519ae00866b58ed5037cbedad04fd15a43cc)) +- **deps:** update typescript-eslint monorepo to v5.45.0 ([#30](https://github.com/dzangolab/fastify/issues/30)) ([0b41dc0](https://github.com/dzangolab/fastify/commit/0b41dc0299e1d4660fe46470fa2decf7033d98f2)) ## [0.0.14](https://github.com/dzangolab/fastify/compare/v0.0.13...v0.0.14) (2022-11-26) - - ## [0.0.13](https://github.com/dzangolab/fastify/compare/v0.0.12...v0.0.13) (2022-11-26) - - ## [0.0.12](https://github.com/dzangolab/fastify/compare/v0.0.11...v0.0.12) (2022-11-26) - - ## [0.0.11](https://github.com/dzangolab/fastify/compare/v0.0.10...v0.0.11) (2022-11-26) - - ## [0.0.10](https://github.com/dzangolab/fastify/compare/v0.0.9...v0.0.10) (2022-11-26) - ### Bug Fixes -* **deps:** update dependency eslint-plugin-unicorn to v45 ([#22](https://github.com/dzangolab/fastify/issues/22)) ([0ef20bd](https://github.com/dzangolab/fastify/commit/0ef20bd8fcc85aeef05b4ba345c5c349263e29e9)) -* **deps:** update dependency eslint-plugin-vue to v9.8.0 ([#19](https://github.com/dzangolab/fastify/issues/19)) ([cac06ea](https://github.com/dzangolab/fastify/commit/cac06ea2860e0294a48bbd9d493dc8f1b7e54c4c)) -* **deps:** update typescript-eslint monorepo to v5.44.0 ([#20](https://github.com/dzangolab/fastify/issues/20)) ([6a9a579](https://github.com/dzangolab/fastify/commit/6a9a579e3b241515d46a4c2e7a40de6e88999317)) - - +- **deps:** update dependency eslint-plugin-unicorn to v45 ([#22](https://github.com/dzangolab/fastify/issues/22)) ([0ef20bd](https://github.com/dzangolab/fastify/commit/0ef20bd8fcc85aeef05b4ba345c5c349263e29e9)) +- **deps:** update dependency eslint-plugin-vue to v9.8.0 ([#19](https://github.com/dzangolab/fastify/issues/19)) ([cac06ea](https://github.com/dzangolab/fastify/commit/cac06ea2860e0294a48bbd9d493dc8f1b7e54c4c)) +- **deps:** update typescript-eslint monorepo to v5.44.0 ([#20](https://github.com/dzangolab/fastify/issues/20)) ([6a9a579](https://github.com/dzangolab/fastify/commit/6a9a579e3b241515d46a4c2e7a40de6e88999317)) ## [0.0.9](https://github.com/dzangolab/fastify/compare/v0.0.6...v0.0.9) (2022-11-25) - - ## [0.0.8](https://github.com/dzangolab/fastify/compare/v0.0.6...v0.0.8) (2022-11-25) - - ## [0.0.7](https://github.com/dzangolab/fastify/compare/v0.0.6...v0.0.7) (2022-11-25) - - ## 0.0.6 (2022-11-24) - - ## 0.0.5 (2022-11-24) diff --git a/README.md b/README.md index 89bd32b84..d93bea1d7 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,68 @@ # @prefabs.tech/fastify -A set of fastify libraries +A set of fastify libraries ## Packages - - @prefabs.tech/fastify-config (https://www.npmjs.com/package/@prefabs.tech/fastify-config) - - @prefabs.tech/fastify-graphql (https://www.npmjs.com/package/@prefabs.tech/fastify-graphql) - - @prefabs.tech/fastify-mailer (https://www.npmjs.com/package/@prefabs.tech/fastify-mailer) - - @prefabs.tech/fastify-s3 (https://www.npmjs.com/package/@prefabs.tech/fastify-s3) - - @prefabs.tech/fastify-slonik (https://www.npmjs.com/package/@prefabs.tech/fastify-slonik) - - @prefabs.tech/fastify-user (https://www.npmjs.com/package/@prefabs.tech/fastify-user) + +- @prefabs.tech/fastify-config (https://www.npmjs.com/package/@prefabs.tech/fastify-config) +- @prefabs.tech/fastify-graphql (https://www.npmjs.com/package/@prefabs.tech/fastify-graphql) +- @prefabs.tech/fastify-mailer (https://www.npmjs.com/package/@prefabs.tech/fastify-mailer) +- @prefabs.tech/fastify-s3 (https://www.npmjs.com/package/@prefabs.tech/fastify-s3) +- @prefabs.tech/fastify-slonik (https://www.npmjs.com/package/@prefabs.tech/fastify-slonik) +- @prefabs.tech/fastify-user (https://www.npmjs.com/package/@prefabs.tech/fastify-user) ## Installation & Usage ### Install dependencies + Install dependencies recursively with this command + ``` make install ``` ### Build all packages + ``` make build ``` ### Lint code + ``` make lint ``` ### Typecheck code + ``` make typecheck ``` ### Test + ``` make test ``` ## Developing locally & testing + The best way to verify the changes done to the libraries is to test them locally before releasing them. To test libraries locally link each libraries to the `fastify-api` using `pnpm link` command. [More on pnpm link](https://pnpm.io/cli/link). To link and unlink the library locally run these commands from the `fastify-api` where you are linking the library: + ``` pnpm link .//packages/ ``` To unlink the linked library + ``` pnpm unlink .//packages/ ``` ## Troubleshooting - - Make sure that `package.json` and `pnpm-lock.yml` are synchronized. - - You may need to restart your fastify api before link and unlink to see the changes. - - All the libraries that defines or uses context has to be linked in order to link one libraries that use the context or defines it. + +- Make sure that `package.json` and `pnpm-lock.yml` are synchronized. +- You may need to restart your fastify api before link and unlink to see the changes. +- All the libraries that defines or uses context has to be linked in order to link one libraries that use the context or defines it. diff --git a/package.json b/package.json index 352b97ab2..a9edbe594 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify", - "version": "0.93.5", + "version": "0.94.0", "private": true, "repository": { "type": "git", @@ -12,21 +12,33 @@ "lint:fix": "turbo run lint:fix", "prepare": "husky", "sort-package": "npx sort-package-json && turbo run sort-package", - "test": "turbo run test --parallel", + "test": "turbo run test", "typecheck": "turbo run typecheck" }, "devDependencies": { - "@commitlint/cli": "20.4.1", - "@commitlint/config-conventional": "20.4.1", - "@types/node": "24.10.13", + "@commitlint/cli": "20.5.3", + "@commitlint/config-conventional": "20.5.3", + "@types/node": "24.10.15", "husky": "9.1.7", - "shipjs": "0.28.2", - "turbo": "2.8.7", + "shipjs": "0.28.3", + "turbo": "2.9.9", "typescript": "5.9.3" }, "packageManager": "pnpm@10.29.3", "engines": { "node": ">=20", "pnpm": ">=10" + }, + "pnpm": { + "overrides": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "fast-xml-parser": "5.7.1", + "handlebars": "4.7.9", + "protobufjs": "7.5.5", + "typescript-eslint": "8.58.0" + } } } diff --git a/packages/config/ADR-CONFIG.md b/packages/config/ADR-CONFIG.md new file mode 100644 index 000000000..142d86e4a --- /dev/null +++ b/packages/config/ADR-CONFIG.md @@ -0,0 +1,426 @@ +# ADR: Application Configuration Architecture + +**Date:** 2026-04-02 +**Status:** Proposed +**Decision Makers:** Engineering Team +**Affected Components:** `@prefabs.tech/fastify-config`, app-level configuration + +--- + +## Problem Statement + +Our multi-package Fastify application requires a configuration system that: + +1. **Supports extensibility** — Core packages (config, logger, mailer, firebase, etc.) need config, but the app layer may add domain-specific config (booking, event, redis, user profile exports) that packages can't know about beforehand +2. **Maintains type safety** — TypeScript should catch config shape mismatches at compile time +3. **Enables runtime validation** — Invalid or missing config should fail fast at startup, not at 3am in production +4. **Minimizes coupling** — The @config package shouldn't need to know about every package that needs configuration +5. **Remains maintainable** — Configuration architecture should be clear, with tradeoffs documented + +Current pain points: + +- `ApiConfig` type is centralized in `@config` but needs to be extended by the app for custom fields +- Config is constructed from environment variables without schema validation +- App-level config augmentation is scattered via TypeScript module declarations +- If a required env var is missing, the error appears at runtime, not at startup + +--- + +## Options Considered + +### Option 1: Centralized ApiConfig (Current Baseline) + +**How it works:** + +- All config fields defined in `ApiConfig` interface within `@config` package +- `@config` package "knows about" all packages and app-level needs +- Single point of truth for type definition +- Config decorator on fastify instance and request + +```ts +// @config/types.ts +export interface ApiConfig { + appName: string; + firebase: FirebaseConfig; + logger: LoggerConfig; + mailer: MailerConfig; + + // ... 20 more packages + booking: { + /* app-level */ + }; + event: { + /* app-level */ + }; + redis: { + /* app-level */ + }; + // ... custom app fields +} +``` + +**Tradeoffs:** + +| Aspect | Rating | Notes | +| ------------------- | -------------------- | -------------------------------------------------------- | +| **Extensibility** | ⭐ Poor | Every new config field requires changing @config package | +| **Type Safety** | ⭐⭐⭐⭐⭐ Excellent | Single source of truth, all fields typed | +| **Validation** | ⭐⭐ Poor | Manual object construction, no schema validation | +| **Coupling** | ⭐⭐ High | @config must know about all packages and app needs | +| **Maintainability** | ⭐⭐ Poor | ApiConfig grows unbounded; becomes a monolithic type | +| **Discoverability** | ⭐⭐⭐ Moderate | Need to look in one file, but it becomes huge | + +**When to use:** Small, stable applications with a fixed set of known config fields. + +--- + +### Option 2: Module Augmentation at App Level (Currently In Use) + +**How it works:** + +- Base `ApiConfig` defined in `@config` with core fields only +- App level extends ApiConfig via TypeScript module declaration +- Each package can also have its config interface extended at app level +- Config object constructed manually from env vars at app startup + +```ts +// @config/types.ts (minimal) +export interface ApiConfig { + appName: string; + env: string; + port: number; +} + +// app/config.ts (app level augmentation) +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + booking: { + /* ... */ + }; + event: { + /* ... */ + }; + redis: { + /* ... */ + }; + } +} + +declare module "@prefabs.tech/fastify-mailer" { + interface MailerConfig { + bccTo?: string; + queueName: string; + } +} + +const config: ApiConfig = { + appName: process.env.APP_NAME as string, + booking: { + /* manually constructed */ + }, + // ... +}; +``` + +**Tradeoffs:** + +| Aspect | Rating | Notes | +| ------------------- | --------------- | -------------------------------------------------------------- | +| **Extensibility** | ⭐⭐⭐⭐ Good | App can add fields without touching packages | +| **Type Safety** | ⭐⭐⭐⭐ Good | TypeScript augmentation provides compile-time checks | +| **Validation** | ⭐ Poor | No schema validation; relies on type casting (`as string`) | +| **Coupling** | ⭐⭐⭐ Moderate | @config minimal, but app must know about all packages | +| **Maintainability** | ⭐⭐⭐ Moderate | Module declarations can be hard to track; spread across files | +| **Discoverability** | ⭐⭐ Poor | Config shape scattered across multiple `declare module` blocks | + +**When to use:** Multi-package systems where the app layer has significant custom config, and type safety is more important than runtime validation. + +**Current Status:** This is what the codebase currently implements. + +**Known Issues:** + +- Missing env vars return `undefined`, not caught until runtime +- No schema validation at startup +- If you do `config.appName.toLowerCase()` and `APP_NAME` env var is missing, crash at runtime + +--- + +### Option 3: Plugin-Driven Schema Composition (Recommended) + +**How it works:** + +- Each package exports a schema fragment for its config section +- App composes all schemas at startup (one place, one file) +- Zod (or similar) validates the entire config structure before decoration +- Single point of truth shifts from type definitions to schema + composition +- Runtime validation catches errors at startup, not in production + +```ts +// @config/validator.ts +export const coreConfigSchema = z.object({ + appName: z.string().min(1), + env: z.enum(["dev", "test", "prod"]), + port: z.number().positive(), +}) + +// @logger/config.ts +export const loggerConfigSchema = z.object({ + level: z.string(), + rotation: z.object({...}).optional(), +}) + +// @mailer/config.ts +export const mailerConfigSchema = z.object({ + host: z.string(), + port: z.number().positive(), +}) + +// app/config.ts - single composition point +import { coreConfigSchema } from "@config" +import { loggerConfigSchema } from "@logger" +import { mailerConfigSchema } from "@mailer" + +const appConfigSchema = z.object({ + ...coreConfigSchema.shape, + booking: z.object({...}), + event: z.object({...}), + logger: loggerConfigSchema.optional(), + mailer: mailerConfigSchema.optional(), + redis: z.object({...}), +}) + +// Validate at startup +const config = appConfigSchema.parse({ + appName: process.env.APP_NAME, + port: parseInt(process.env.PORT || "3000"), + // ... +}) +``` + +**Tradeoffs:** + +| Aspect | Rating | Notes | +| ------------------- | -------------------- | ---------------------------------------------------------------- | +| **Extensibility** | ⭐⭐⭐⭐⭐ Excellent | Add new config = add new schema fragment, no touching other code | +| **Type Safety** | ⭐⭐⭐⭐⭐ Excellent | Schemas infer types; TypeScript knows full shape via `z.infer<>` | +| **Validation** | ⭐⭐⭐⭐⭐ Excellent | Runtime schema validation at startup; fail fast | +| **Coupling** | ⭐⭐⭐⭐ Good | Packages export schemas; app composes them; minimal coupling | +| **Maintainability** | ⭐⭐⭐⭐ Good | One composition file; schemas live with packages; easy to track | +| **Discoverability** | ⭐⭐⭐⭐ Good | Config shape visible in app-level schema composition | + +**When to use:** Growing multi-package systems, microservices architectures, or any application where config extensibility and runtime safety are important. + +**Advantages:** + +- **Fail-fast guarantee** — Missing or invalid env vars error at startup +- **Single composition point** — All config decisions visible in one file +- **Clear ownership** — Each package owns its schema +- **Type + validation together** — Zod generates types from schema, no duplication +- **Testable** — Schemas can be unit-tested independently + +**Disadvantages:** + +- Requires adding Zod (or similar) dependency +- Slightly more setup initially +- Need to maintain both schema + values in construction + +--- + +### Option 4: Loose Config with Per-Package Validation (Not Recommended) + +**How it works:** + +- Minimal central config structure +- Each package validates its own config section at plugin registration time +- No central schema; validation scattered + +```ts +// @config - minimal +export const baseConfig = { appName, port, env }; + +// @logger - validates its own section +export const validateLoggerConfig = (cfg) => loggerConfigSchema.parse(cfg); + +fastify.register(loggerPlugin, { config: fullConfig }); +// loggerPlugin internally extracts and validates config.logger +``` + +**Tradeoffs:** + +| Aspect | Rating | Notes | +| ------------------- | -------------------- | ------------------------------------------------------ | +| **Extensibility** | ⭐⭐⭐⭐ Good | Packages are decoupled from central config | +| **Type Safety** | ⭐⭐ Poor | Per-package validation, no centralized type | +| **Validation** | ⭐⭐⭐ Moderate | Validation happens at plugin registration, not startup | +| **Coupling** | ⭐⭐⭐⭐⭐ Excellent | Maximum decoupling; packages validate independently | +| **Maintainability** | ⭐⭐ Poor | Validation logic scattered across packages | +| **Discoverability** | ⭐ Very Poor | Config shape is invisible until plugins register | + +**When to use:** Very loosely-coupled systems or if you want each package to be independently reusable. + +**Not recommended because:** Errors discovered late (at plugin registration time, not startup), config shape opaque. + +--- + +## Comparison Matrix + +| Criterion | Option 1: Centralized | Option 2: App Augmentation | Option 3: Schema Composition | Option 4: Per-Package | +| --------------- | --------------------- | -------------------------- | ---------------------------- | --------------------- | +| Fail-fast | ❌ No | ❌ No | ✅ Yes | ⚠️ Late | +| Type Safety | ✅ Good | ✅ Good | ✅ Excellent | ❌ Weak | +| Extensibility | ❌ Poor | ✅ Good | ✅ Excellent | ✅ Good | +| Coupling | ❌ High | ⚠️ Moderate | ✅ Low | ✅ Very Low | +| Simplicity | ✅ Simple | ✅ Simple | ⚠️ Moderate | ⚠️ Moderate | +| Discoverability | ⚠️ Moderate | ❌ Poor | ✅ Good | ❌ Poor | +| Maintenance | ❌ Monolithic | ⚠️ Scattered | ✅ Centralized | ❌ Scattered | + +--- + +## Recommendation: Option 3 (Plugin-Driven Schema Composition) + +**Why this option:** + +1. **Fail-fast guarantee** — The most critical issue with current setup (Option 2) is that missing env vars aren't caught until they're accessed. Schema validation at startup solves this. + +2. **Clear composition point** — One file shows the entire config shape. Easy to onboard new team members. + +3. **Package ownership** — Each package owns its schema fragment. Encourages good package design. + +4. **Type safety** — Zod infers types from schemas, so you get `z.infer` with full type safety, no manual interface management. + +5. **Scalability** — As you add more packages (redis, elasticsearch, queue systems, etc.), composition scales naturally. + +**Migration Path from Option 2 (current) → Option 3:** + +``` +Phase 1: Add validation without refactoring + - Keep current module augmentation + - Add Zod schema that mirrors current ApiConfig structure + - Validate at startup + +Phase 2: Extract package schemas + - Move logger schema from app to @logger package + - Move mailer schema from app to @mailer package + - Compose in app/config.ts + +Phase 3: Clean up + - Remove manual interface extensions (module augmentations) + - Let Zod infer types via z.infer<> + - Update tests +``` + +--- + +## Implementation Plan + +### If adopting Option 3: + +1. **Add dependency:** `npm install zod` (or pnpm in this monorepo) + +2. **Refactor @config package:** + + ```ts + // @config/validator.ts (new) + export const coreConfigSchema = z.object({ + appName: z.string().min(1), + appOrigin: z.array(z.string()), + baseUrl: z.string(), + env: z.enum(["dev", "test", "prod"]), + logger: z + .object({ + level: z.string(), + // ... nested fields + }) + .optional(), + port: z.number().positive(), + protocol: z.string(), + rest: z.object({ enabled: z.boolean() }), + version: z.string(), + }); + ``` + +3. **Each package exports schema:** + + ```ts + // @mailer/config.ts + export const mailerConfigSchema = z.object({ + bccTo: z.string().optional(), + host: z.string(), + port: z.number().positive(), + queueName: z.string(), + }); + ``` + +4. **App composes at startup:** + + ```ts + // app/config.ts + import { coreConfigSchema } from "@config" + import { mailerConfigSchema } from "@mailer" + + const appConfigSchema = z.object({ + ...coreConfigSchema.shape, + booking: z.object({...}), + mailer: mailerConfigSchema.optional(), + // ... app-specific + }) + + export const config = appConfigSchema.parse({ + appName: process.env.APP_NAME, + // ... construct from env + }) + ``` + +5. **Update fastify decorations:** + ```ts + // @config/index.ts + declare module "fastify" { + interface FastifyInstance { + config: z.infer; // Inferred from app schema + } + } + ``` + +--- + +## Consequences + +### Positive + +- ✅ **Fail-fast** — Invalid config caught at startup, not in production +- ✅ **Type-safe** — Full TypeScript coverage of config shape +- ✅ **Extensible** — Adding new config sections doesn't require changes to @config +- ✅ **Maintainable** — One clear composition point for all config +- ✅ **Testable** — Schemas can be unit-tested in isolation + +### Negative + +- ⚠️ **New dependency** — Adds Zod to the stack (but it's standard practice) +- ⚠️ **Slightly more boilerplate** — Schema definitions alongside construction +- ⚠️ **Learning curve** — Team needs to understand schema composition + +### Neutral + +- 🔹 **Migration effort** — Requires refactoring existing config code, but manageable +- 🔹 **Slight performance** — Schema validation at startup (negligible, runs once) + +--- + +## Decision + +**Adopt Option 3: Plugin-Driven Schema Composition** + +**Rationale:** + +- Solves the critical validation gap in the current approach (Option 2) +- Provides the scalability needed for a growing, multi-package application +- Aligns with Node.js best practices for configuration management +- Minimal risk, clear migration path from current state + +--- + +## References + +- Zod Documentation: https://zod.dev +- 12 Factor App - Config: https://12factor.net/config +- Node.js Best Practices - Configuration: https://github.com/goldbergyoni/nodebestpractices#6-configuration diff --git a/packages/config/FEATURES.md b/packages/config/FEATURES.md new file mode 100644 index 000000000..ba96d5067 --- /dev/null +++ b/packages/config/FEATURES.md @@ -0,0 +1,64 @@ + + +## Plugin Registration + +1. Registers as a Fastify plugin via `fastify-plugin` (no encapsulation — decorators are visible across the full app). +2. Accepts a single required option: `{ config: ApiConfig }`. +3. Provides no internal defaults for plugin options — callers must provide a complete `ApiConfig` object. + +## Fastify Decorators + +4. Decorates `FastifyInstance` with `config` (the full `ApiConfig` object). +5. Decorates `FastifyInstance` with `hostname` (derived as `${config.baseUrl}:${config.port}`). + +## Fastify Hooks + +6. Registers an `onRequest` hook that sets `request.config` to the same `ApiConfig` object on every incoming request. + +## Utility Functions + +7. Exports a `parse` utility for converting raw `string | undefined` env var values to typed values using a fallback to infer the target type: + - Returns `fallback` when `value` is `undefined`. + - Returns a `boolean` (via `!!JSON.parse(value)`) when `fallback` is a `boolean`. + - Returns a `number` (via `JSON.parse(value)`) when `fallback` is a `number`. + - Returns the raw `string` otherwise. + + ```typescript + parse(process.env.PORT, 3000); // → number + parse(process.env.DEBUG, false); // → boolean + parse(process.env.APP_NAME, "my-app"); // → string + parse(undefined, 3000); // → 3000 (fallback) + ``` + +## Type Exports & Module Augmentation + +8. Exports `ApiConfig` type — the shape of the full application configuration object. +9. Exports `AppConfig` type — the shape of an individual app entry within `ApiConfig.apps`. +10. Module-augments `FastifyInstance` to add `config: ApiConfig` and `hostname: string`. +11. Module-augments `FastifyRequest` to add `config: ApiConfig`. + +## ApiConfig Shape + +12. `ApiConfig` top-level fields: + - `appName: string` + - `appOrigin: string[]` + - `apps?: AppConfig[]` + - `baseUrl: string` + - `env: string` + - `logger` — see feature 12 + - `name: string` + - `pagination?: { default_limit: number; max_limit: number }` + - `port: number` + - `protocol: string` + - `rest: { enabled: boolean }` + - `version: string` + +13. `ApiConfig.logger` sub-object: + - `level: Level` (required) + - `base?: LoggerOptions["base"]` + - `formatters?: LoggerOptions["formatters"]` + - `prettyPrint?: { options: { colorize: boolean; ignore: string; translateTime: string } }` + - `rotation?: { enabled: boolean; options: { filenames: string[]; path: string; interval?: string; size?: string; maxFiles?: number; maxSize?: string; compress?: boolean | Compressor | string } }` + - `streams?: (DestinationStream | StreamEntry)[]` + - `timestamp?: LoggerOptions["timestamp"]` + - `transport?: LoggerOptions["transport"]` diff --git a/packages/config/GUIDE.md b/packages/config/GUIDE.md new file mode 100644 index 000000000..c9707145d --- /dev/null +++ b/packages/config/GUIDE.md @@ -0,0 +1,269 @@ +# @prefabs.tech/fastify-config — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-config +``` + +```bash +pnpm add @prefabs.tech/fastify-config +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-config test +pnpm --filter @prefabs.tech/fastify-config build +``` + +## Setup + +Register the plugin once at startup, passing your config object. All subsequent examples assume this setup. + +```typescript +import Fastify from "fastify"; +import configPlugin, { type ApiConfig } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + appName: "my-api", + appOrigin: ["https://app.example.com"], + baseUrl: "http://localhost", + env: "production", + logger: { level: "info" }, + name: "my-api", + port: 3000, + protocol: "https", + rest: { enabled: true }, + version: "1.0.0", +}; + +const fastify = Fastify(); +await fastify.register(configPlugin, { config }); +``` + +--- + +## Base Libraries + +### `fastify-plugin` — Modified + +`fastify-plugin` provides Fastify plugin wrapping and metadata controls. + +-> **Their docs:** [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) + +We wrap this library with a different surface: + +- Consumers do not pass `fastify-plugin` metadata options (`name`, `dependencies`, Fastify version metadata, etc.). +- The exposed API is only `fastify.register(configPlugin, { config })`. +- Internally we use the wrapper to expose our decorators and hooks application-wide. + +**What we add on top:** + +- `fastify.config` decorator with your `ApiConfig` object +- `fastify.hostname` decorator derived from `baseUrl` and `port` +- `request.config` population via `onRequest` +- Type exports and Fastify module augmentation +- `parse` utility for typed env parsing + +--- + +## Features + +### Plugin registration contract + +The plugin takes one required option (`config`) and does not apply internal defaults. Pass a full `ApiConfig` object at registration time. + +```typescript +await fastify.register(configPlugin, { + config: { + appName: "my-api", + appOrigin: ["https://app.example.com"], + baseUrl: "http://localhost", + env: "production", + logger: { level: "info" }, + name: "my-api", + port: 3000, + protocol: "https", + rest: { enabled: true }, + version: "1.0.0", + }, +}); +``` + +### `fastify.config` decorator + +After registration, the full `ApiConfig` object is available on every `FastifyInstance`: + +```typescript +fastify.get("/status", async () => { + return { env: fastify.config.env, version: fastify.config.version }; +}); +``` + +### `fastify.hostname` decorator + +A computed `hostname` string (`${config.baseUrl}:${config.port}`) is available on `FastifyInstance`: + +```typescript +fastify.get("/info", async () => { + return { url: fastify.hostname }; + // → "http://localhost:3000" +}); +``` + +### `request.config` on every request + +An `onRequest` hook makes `config` available on every `FastifyRequest`, so route handlers can access it without importing globals: + +```typescript +fastify.get("/me", async (request) => { + return { origin: request.config.appOrigin }; +}); +``` + +### `parse` — typed env var parser + +The exported `parse` utility converts raw environment variables to their intended types. Pass a fallback value; its type determines how the string is coerced. + +```typescript +import { parse } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + port: parse(process.env.PORT, 3000) as number, + env: parse(process.env.NODE_ENV, "development") as string, + // ... +}; +``` + +Rules: + +- `value === undefined` → returns `fallback` +- `typeof fallback === "boolean"` → `!!JSON.parse(value)` (`"true"`/`"1"` → `true`, `"false"`/`"0"` → `false`) +- `typeof fallback === "number"` → `JSON.parse(value)` +- Otherwise → returns `value` as-is (string) +- Invalid JSON-like input in boolean/number mode propagates `SyntaxError` from `JSON.parse`. + +### `ApiConfig` type + +The full application config shape. Top-level fields: + +| Field | Type | Description | +| ------------ | ------------------------------ | ------------------------------------- | +| `appName` | `string` | Application name | +| `appOrigin` | `string[]` | Allowed origins | +| `apps` | `AppConfig[]` | Optional sub-app list | +| `baseUrl` | `string` | Base URL (used to compute `hostname`) | +| `env` | `string` | Deployment environment | +| `logger` | object | Logger configuration (see below) | +| `name` | `string` | Service name | +| `pagination` | `{ default_limit, max_limit }` | Optional pagination defaults | +| `port` | `number` | Port (used to compute `hostname`) | +| `protocol` | `string` | Transport protocol | +| `rest` | `{ enabled: boolean }` | REST transport toggle | +| `version` | `string` | Application version | + +#### `logger` sub-object + +| Field | Type | Notes | +| ------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `level` | `Level` | Required pino log level | +| `base` | `LoggerOptions["base"]` | Pino base fields | +| `formatters` | `LoggerOptions["formatters"]` | Pino log formatters | +| `prettyPrint` | object | Pretty-print options (`colorize`, `ignore`, `translateTime`) | +| `rotation` | object | Log rotation (`enabled`, `filenames`, `path`, `interval`, `size`, `maxFiles`, `maxSize`, `compress`) | +| `streams` | `(DestinationStream \| StreamEntry)[]` | Pino stream list | +| `timestamp` | `LoggerOptions["timestamp"]` | Pino timestamp | +| `transport` | `LoggerOptions["transport"]` | Pino transport | + +### `AppConfig` type + +Shape of each entry in `ApiConfig.apps`: + +```typescript +interface AppConfig { + id: number; + name: string; + origin: string; + supportedRoles: string[]; +} +``` + +### TypeScript module augmentation + +The plugin ships with module augmentation for Fastify's types. No extra setup is needed — `fastify.config`, `fastify.hostname`, and `request.config` are typed automatically after importing from this package. + +--- + +## Use Cases + +### Building config from environment variables + +Use `parse` to construct a fully typed `ApiConfig` from `process.env`: + +```typescript +import { parse } from "@prefabs.tech/fastify-config"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +const config: ApiConfig = { + appName: parse(process.env.APP_NAME, "my-api") as string, + appOrigin: (process.env.APP_ORIGIN ?? "http://localhost:3000").split(","), + baseUrl: parse(process.env.BASE_URL, "http://localhost") as string, + env: parse(process.env.NODE_ENV, "development") as string, + logger: { + level: parse(process.env.LOG_LEVEL, "info") as string as Level, + }, + name: "my-api", + port: parse(process.env.PORT, 3000) as number, + protocol: parse(process.env.PROTOCOL, "http") as string, + rest: { enabled: parse(process.env.REST_ENABLED, true) as boolean }, + version: parse(process.env.APP_VERSION, "0.0.0") as string, +}; +``` + +### Accessing config in a route handler + +Config is available on both the instance and the request object, so you can use whichever is in scope: + +```typescript +// Via instance (useful in plugin scope) +fastify.get("/version", async () => ({ version: fastify.config.version })); + +// Via request (useful in route handlers without fastify in closure) +fastify.get("/origin", async (request) => ({ + origin: request.config.appOrigin, +})); +``` + +### Multi-app origin checking + +`ApiConfig.apps` lets you describe multiple sub-applications with their own origins and roles: + +```typescript +const config: ApiConfig = { + // ...base config... + apps: [ + { + id: 1, + name: "dashboard", + origin: "https://dash.example.com", + supportedRoles: ["admin"], + }, + { + id: 2, + name: "portal", + origin: "https://portal.example.com", + supportedRoles: ["user", "admin"], + }, + ], +}; + +fastify.addHook("onRequest", async (request) => { + const origin = request.headers.origin ?? ""; + const app = request.config.apps?.find((a) => a.origin === origin); + if (!app) throw fastify.httpErrors.forbidden("Unknown origin"); +}); +``` diff --git a/packages/config/README.md b/packages/config/README.md index 6964063d7..b87e44335 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -1,85 +1,84 @@ # @prefabs.tech/fastify-config -A [Fastify](https://github.com/fastify/fastify) plugin that defines an opinionated config for an API. +A [Fastify](https://github.com/fastify/fastify) plugin that provides opinionated, typed configuration management for APIs. -When registered on a Fastify instance, the plugin will: +## Why This Plugin? -* decorate the Fastify instance with the `config` object, available with the `config` attribute. -* decorate all requests with the `config` object, available with the `config` attribute; this can be used to construct a `buildContext` for mercurius resolvers, for example. -* decorate the Fastify instance with a `hostname` attribute. +In a complex API or monorepo with multiple Fastify plugins and services, maintaining a standardized configuration structure is critical. This plugin enforces a consistent config shape across services, centralizes config access at both the instance and request level, and provides a lightweight utility for parsing environment variables — without pulling in heavy validation dependencies like `zod` or `ajv`. -## Installation +### Why not Zod or @fastify/env? -Install with npm: +1. **No runtime validation overhead** — if your infrastructure (CI/CD, Docker, Kubernetes) guarantees correct environment variable injection, strict runtime validation is unnecessary overhead. +2. **Lightweight footprint** — no `ajv` or `zod` means less bundle size and fewer transitive dependencies. +3. **Manual type definitions** — hand-crafted TypeScript interfaces give immediate IDE support across your monorepo without extra build steps. -```bash -npm install @prefabs.tech/fastify-config -``` +## What You Get -Install with pnpm: +### Added by This Plugin -```bash -pnpm add --filter "@scope/project @prefabs.tech/fastify-config -``` +- **`fastify.config`** — decorates the Fastify instance with your `ApiConfig` object, accessible everywhere on the instance +- **`request.config`** — decorates every incoming request with the same config reference via an `onRequest` hook (useful for mercurius `buildContext`, route handlers, etc.) +- **`fastify.hostname`** — computed `${baseUrl}:${port}` string, derived from your config +- **`parse(value, fallback)`** — type-coercing env var parser: returns a boolean, number, or string based on the fallback type; returns the fallback when the value is `undefined` +- **`ApiConfig` type** — strongly typed interface covering app identity, origins, logging (pino), pagination, REST feature flag, and multi-tenant app list +- **`AppConfig` type** — per-app shape for multi-tenant configurations (`id`, `name`, `origin`, `supportedRoles`) + +→ [Full feature list](FEATURES.md) · [Developer guide](GUIDE.md) + +## Requirements + +**Peer dependencies** (must be installed separately): -## Usage +- [`fastify`](https://www.npmjs.com/package/fastify) `>=5.2.1` +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) `>=5.0.1` -Somewhere in your code, create a `config.ts` file that looks like this: +No sibling `@prefabs.tech` plugins need to be registered before this one. + +## Quick Start ```typescript +// config.ts import { parse } from "@prefabs.tech/fastify-config"; -import dotenv from "dotenv"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; -dotenv.config(); - const config: ApiConfig = { appName: process.env.APP_NAME as string, appOrigin: (process.env.APP_ORIGIN as string).split(","), baseUrl: process.env.BASE_URL as string, env: parse(process.env.NODE_ENV, "development") as string, - logger: { - level: parse(process.env.LOG_LEVEL, "error") as string, - }, + logger: { level: parse(process.env.LOG_LEVEL, "error") as string }, name: process.env.NAME as string, - pagination: { - default_limit: parse(process.env.PAGINATION_DEFAULT_LIMIT, 25) as number, - max_limit: parse(process.env.PAGINATION_MAX_LIMIT, 50) as number, - }, - port: parse(process.env.PORT, 20040) as number, + port: parse(process.env.PORT, 3000) as number, protocol: parse(process.env.PROTOCOL, "http") as string, - rest: { - enabled: parse(process.env.REST_ENABLED, true) as boolean, - }, - version: `${process.env.npm_package_version || process.env.API_VERSION}+${process.env.API_BUILD || "local"}` as string, + rest: { enabled: parse(process.env.REST_ENABLED, true) as boolean }, + version: `${process.env.npm_package_version}+${process.env.BUILD_ID || "local"}`, }; export default config; ``` -Register the plugin with your Fastify instance: - ```typescript +// server.ts import configPlugin from "@prefabs.tech/fastify-config"; import Fastify from "fastify"; - import config from "./config"; -const start = async () => { - // Create fastify instance - const fastify = Fastify({ - logger: config.logger, - }); +const fastify = Fastify({ logger: config.logger }); +await fastify.register(configPlugin, { config }); - // Register fastify-config plugin - await fastify.register(configPlugin, { config }); +await fastify.listen({ port: config.port, host: "0.0.0.0" }); +``` - await fastify.listen({ - port: config.port, - host: "0.0.0.0", - }); -}; +## Installation + +Install with npm: -start(); +```bash +npm install @prefabs.tech/fastify-config +``` + +Install with pnpm: + +```bash +pnpm add @prefabs.tech/fastify-config ``` diff --git a/packages/config/package.json b/packages/config/package.json index 8eb7918d5..cf3e30354 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-config", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify config plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/config#readme", "repository": { @@ -31,21 +31,21 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/tsconfig": "0.5.0", - "@types/node": "24.10.13", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@types/node": "24.10.15", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", "pino": "8.21.0", - "prettier": "3.8.1", + "prettier": "3.8.3", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4" }, "peerDependencies": { - "fastify": ">=5.2.1", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1" }, "engines": { diff --git a/packages/config/src/__test__/parse.test.ts b/packages/config/src/__test__/parse.test.ts index 67beec5cb..45f2913c6 100644 --- a/packages/config/src/__test__/parse.test.ts +++ b/packages/config/src/__test__/parse.test.ts @@ -37,19 +37,31 @@ describe("parse", () => { expect(parse(undefined, undefined)).toBe(undefined); }); - it("throws SyntaxError Exception due to json parse on boolean", () => { - try { - parse("Dzango", false); - } catch (error) { - expect(error).toBeInstanceOf(SyntaxError); - } - }); - - it("returns SyntaxError Exception due to json parse on number", () => { - try { - parse("Dzango", 14); - } catch (error) { - expect(error).toBeInstanceOf(SyntaxError); - } + it("throws SyntaxError when boolean parsing receives invalid JSON", () => { + expect(() => parse("Dzango", false)).toThrow(SyntaxError); + }); + + it("throws SyntaxError when number parsing receives invalid JSON", () => { + expect(() => parse("Dzango", 14)).toThrow(SyntaxError); + }); + + it('parses "1" as truthy boolean', () => { + expect(parse("1", false)).toBe(true); + }); + + it('parses "0" as falsy boolean', () => { + expect(parse("0", true)).toBe(false); + }); + + it("parses a float number", () => { + expect(parse("3.14", 0)).toBe(3.14); + }); + + it("parses a negative number", () => { + expect(parse("-5", 0)).toBe(-5); + }); + + it("returns empty string when value is empty string", () => { + expect(parse("", "default")).toBe(""); }); }); diff --git a/packages/config/src/__test__/plugin.test.ts b/packages/config/src/__test__/plugin.test.ts new file mode 100644 index 000000000..02e64f777 --- /dev/null +++ b/packages/config/src/__test__/plugin.test.ts @@ -0,0 +1,289 @@ +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ApiConfig } from "../types"; + +import configPlugin from "../plugin"; + +const baseConfig: ApiConfig = { + appName: "TestApp", + appOrigin: ["http://localhost:3000"], + baseUrl: "http://localhost", + env: "test", + logger: { level: "silent" }, + name: "test-api", + port: 3000, + protocol: "http", + rest: { enabled: true }, + version: "1.0.0+test", +}; + +describe("configPlugin — registration", () => { + it("registers without throwing given a valid config", async () => { + const fastify = Fastify({ logger: false }); + await expect( + fastify.register(configPlugin, { config: baseConfig }), + ).resolves.not.toThrow(); + await fastify.close(); + }); + + it("fails registration when config option is not provided", async () => { + const fastify = Fastify({ logger: false }); + + await expect( + fastify.register( + configPlugin, + {} as unknown as { + config: ApiConfig; + }, + ), + ).rejects.toThrow(); + + await fastify.close(); + }); +}); + +describe("configPlugin — fastify.config decorator", () => { + let fastify: ReturnType; + + beforeEach(async () => { + fastify = Fastify({ logger: false }); + await fastify.register(configPlugin, { config: baseConfig }); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("exposes the exact config object on fastify.config", () => { + expect(fastify.config).toBe(baseConfig); + }); + + it("exposes correct appName", () => { + expect(fastify.config.appName).toBe("TestApp"); + }); + + it("exposes correct port", () => { + expect(fastify.config.port).toBe(3000); + }); + + it("exposes correct env", () => { + expect(fastify.config.env).toBe("test"); + }); + + it("exposes correct version", () => { + expect(fastify.config.version).toBe("1.0.0+test"); + }); + + it("exposes correct appOrigin array", () => { + expect(fastify.config.appOrigin).toEqual(["http://localhost:3000"]); + }); + + it("exposes correct rest.enabled flag", () => { + expect(fastify.config.rest.enabled).toBe(true); + }); + + it("optional apps field is undefined when not provided", () => { + expect(fastify.config.apps).toBeUndefined(); + }); + + it("optional pagination field is undefined when not provided", () => { + expect(fastify.config.pagination).toBeUndefined(); + }); +}); + +describe("configPlugin — fastify.hostname decorator", () => { + it("computes hostname as baseUrl:port", async () => { + const fastify = Fastify({ logger: false }); + await fastify.register(configPlugin, { config: baseConfig }); + await fastify.ready(); + + expect(fastify.hostname).toBe("http://localhost:3000"); + await fastify.close(); + }); + + it("reflects different baseUrl and port values", async () => { + const fastify = Fastify({ logger: false }); + const config: ApiConfig = { + ...baseConfig, + baseUrl: "https://api.example.com", + port: 8080, + }; + await fastify.register(configPlugin, { config }); + await fastify.ready(); + + expect(fastify.hostname).toBe("https://api.example.com:8080"); + await fastify.close(); + }); +}); + +describe("configPlugin — req.config request decorator", () => { + let fastify: ReturnType; + + beforeEach(async () => { + fastify = Fastify({ logger: false }); + await fastify.register(configPlugin, { config: baseConfig }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("populates req.config in a GET route handler", async () => { + fastify.get("/test", async (req) => { + return { appName: req.config.appName }; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ appName: "TestApp" }); + }); + + it("populates req.config in a POST route handler", async () => { + fastify.post("/test", async (req) => { + return { version: req.config.version }; + }); + + const res = await fastify.inject({ method: "POST", url: "/test" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ version: "1.0.0+test" }); + }); + + it("req.config is the same object as fastify.config", async () => { + let requestConfig: ApiConfig | undefined; + + fastify.get("/test", async (req) => { + requestConfig = req.config; + return {}; + }); + + await fastify.inject({ method: "GET", url: "/test" }); + expect(requestConfig).toBe(baseConfig); + }); + + it("req.config is available on every request", async () => { + fastify.get("/test", async (req) => { + return { env: req.config.env }; + }); + + const [res1, res2] = await Promise.all([ + fastify.inject({ method: "GET", url: "/test" }), + fastify.inject({ method: "GET", url: "/test" }), + ]); + + expect(res1.json()).toEqual({ env: "test" }); + expect(res2.json()).toEqual({ env: "test" }); + }); + + it("config values are stable across multiple requests (no mutation)", async () => { + fastify.get("/test", async (req) => { + return { port: req.config.port }; + }); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + fastify.inject({ method: "GET", url: "/test" }), + ), + ); + + for (const res of results) { + expect(res.json()).toEqual({ port: 3000 }); + } + }); +}); + +describe("configPlugin — app-wide visibility (fastify-plugin)", () => { + it("exposes fastify.config, fastify.hostname, and req.config inside a nested child plugin", async () => { + const fastify = Fastify({ logger: false }); + await fastify.register(configPlugin, { config: baseConfig }); + + await fastify.register(async function nestedChildPlugin(child) { + child.get("/nested", async (request) => { + return { + hostname: child.hostname, + instanceHasConfig: "config" in child, + instanceHasHostname: "hostname" in child, + requestAppName: request.config.appName, + }; + }); + }); + + await fastify.ready(); + const res = await fastify.inject({ method: "GET", url: "/nested" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + hostname: "http://localhost:3000", + instanceHasConfig: true, + instanceHasHostname: true, + requestAppName: "TestApp", + }); + await fastify.close(); + }); +}); + +describe("configPlugin — optional config fields", () => { + it("exposes apps array when provided", async () => { + const fastify = Fastify({ logger: false }); + const config: ApiConfig = { + ...baseConfig, + apps: [ + { + id: 1, + name: "WebApp", + origin: "https://web.example.com", + supportedRoles: ["admin", "user"], + }, + ], + }; + await fastify.register(configPlugin, { config }); + + fastify.get("/test", async (req) => { + return { apps: req.config.apps }; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().apps).toHaveLength(1); + expect(res.json().apps[0].name).toBe("WebApp"); + await fastify.close(); + }); + + it("exposes pagination defaults when provided", async () => { + const fastify = Fastify({ logger: false }); + const config: ApiConfig = { + ...baseConfig, + pagination: { default_limit: 20, max_limit: 100 }, + }; + await fastify.register(configPlugin, { config }); + await fastify.ready(); + + expect(fastify.config.pagination?.default_limit).toBe(20); + expect(fastify.config.pagination?.max_limit).toBe(100); + await fastify.close(); + }); + + it("exposes apps array via req.config inside a route", async () => { + const fastify = Fastify({ logger: false }); + const config: ApiConfig = { + ...baseConfig, + apps: [ + { + id: 2, + name: "MobileApp", + origin: "https://mobile.example.com", + supportedRoles: ["user"], + }, + ], + }; + await fastify.register(configPlugin, { config }); + + fastify.get("/test", async (req) => { + return { firstApp: req.config.apps?.[0]?.name }; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json()).toEqual({ firstApp: "MobileApp" }); + await fastify.close(); + }); +}); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 3e4ee33b1..1b90b72ea 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -11,8 +11,8 @@ declare module "fastify" { } } -export { default } from "./plugin"; - export { default as parse } from "./parse"; +export { default } from "./plugin"; + export type { ApiConfig, AppConfig } from "./types"; diff --git a/packages/config/src/plugin.ts b/packages/config/src/plugin.ts index ef71d0fc3..70311c8c1 100644 --- a/packages/config/src/plugin.ts +++ b/packages/config/src/plugin.ts @@ -1,7 +1,8 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import type { ApiConfig } from "./types"; -import type { FastifyInstance, FastifyRequest } from "fastify"; const plugin = async ( fastify: FastifyInstance, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 59b0eea14..56060f738 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -5,15 +5,6 @@ import type { StreamEntry, } from "pino"; -interface AppConfig { - id: number; - name: string; - origin: string; - supportedRoles: string[]; -} - -type Compressor = (source: string, destination: string) => string; - interface ApiConfig { appName: string; appOrigin: string[]; @@ -34,7 +25,7 @@ interface ApiConfig { rotation?: { enabled: boolean; options: { - compress?: boolean | string | Compressor; + compress?: boolean | Compressor | string; filenames: string[]; interval?: string; maxFiles?: number; @@ -44,8 +35,8 @@ interface ApiConfig { }; }; streams?: (DestinationStream | StreamEntry)[]; - transport?: LoggerOptions["transport"]; timestamp?: LoggerOptions["timestamp"]; + transport?: LoggerOptions["transport"]; }; name: string; pagination?: { @@ -60,4 +51,13 @@ interface ApiConfig { version: string; } +interface AppConfig { + id: number; + name: string; + origin: string; + supportedRoles: string[]; +} + +type Compressor = (source: string, destination: string) => string; + export type { ApiConfig, AppConfig }; diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json index 8a8ad62d0..1628077b9 100644 --- a/packages/config/tsconfig.json +++ b/packages/config/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "./dist", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/config/vite.config.ts b/packages/config/vite.config.ts index 00d158a4c..6812de0a4 100644 --- a/packages/config/vite.config.ts +++ b/packages/config/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { peerDependencies } from "./package.json"; diff --git a/packages/error-handler/FEATURES.md b/packages/error-handler/FEATURES.md new file mode 100644 index 000000000..6a5e6dc3c --- /dev/null +++ b/packages/error-handler/FEATURES.md @@ -0,0 +1,69 @@ + + +# @prefabs.tech/fastify-error-handler — Features + +## Plugin Registration + +1. **Registers `@fastify/sensible` with fixed defaults** — adds `fastify.httpErrors` and `HttpError` support automatically; this package does not expose `@fastify/sensible` registration options. + +2. **Adds `ErrorResponse` JSON schema** — registers the `ErrorResponse` schema (`$id: "ErrorResponse"`) with Fastify so routes can reference it in response schemas. + +3. **`stackTrace` and `domainErrorStatusMap` options** — plugin options control stack visibility and optional domain status mapping (`ReadonlyMap`). `domainErrorStatusMap` is validated at registration and copied internally (empty map when omitted). Each configured status code must be an integer in **`400`–`599`** or registration throws. + +## Error Handler + +4. **Global `setErrorHandler`** — installs a single error handler that catches all unhandled errors thrown from routes and plugins. + +5. **Unknown error normalization** — non-`Error` values thrown (e.g. strings, null) are coerced to `new Error("UNKNOWN_ERROR")` before processing. + +6. **HttpError branch** — errors that are `instanceof HttpError` (thrown via `fastify.httpErrors.*`) respond with the original status code, HTTP status text in `error`, and the original message and name. + +7. **Optional `domainErrorStatusMap` branch** — when `error.name` exists as a key in the configured map, the error responds with that HTTP status, `error` (HTTP status text), `message`, `name`, and (for `CustomError`) `code`; **`stackTrace`** only adds the parsed `stack` field (it does not mask or replace these fields for mapped errors). Logging follows the same status-range rules as **severity-based logging** below. + +8. **Non-mapped non-HttpError branch** — all other plain `Error`, `CustomError`, and subclass errors that are not matched by item 7 respond with status `500`. + +9. **`CustomError` code extraction (unmapped)** — when the thrown error is `instanceof CustomError` and not handled by item 7, its `.code` is used in the response (only when `stackTrace: true`; otherwise `"INTERNAL_SERVER_ERROR"` is used). + +10. **Error detail masking (`stackTrace: false`, unmapped only)** — for non-HttpErrors not handled by item 7, the response replaces message, name, and code with safe generic values: + - Plain `Error`: message → `"Server error, please contact support"`, name → `"Error"`, code → `"INTERNAL_SERVER_ERROR"` + - `CustomError`: message → `"Server has an error that is not handled, please contact support"`, name → `"Error"`, code → `"INTERNAL_SERVER_ERROR"` + +11. **Severity-based logging** — `HttpError` instances and **`domainErrorStatusMap`** matches use status ranges (`5xx` logged at `error` level; `4xx` at `info`; below `400` at `error`). Non-mapped non-HttpErrors log at `error` level regardless of `stackTrace`. + +## Stack Traces + +12. **Optional stack trace in responses** — when `stackTrace: true`, error responses include a `stack` array of parsed `StackTracey.Entry` objects (file, line, column, callee) for both HttpErrors and non-HttpErrors. + +13. **Stack trace gated on `error.stack` presence** — if the error has no `.stack` property, the `stack` field is omitted from the response even when `stackTrace: true`. + +## Pre-Error Handler + +14. **`preErrorHandler` option** — an optional async function called before the default handler, receiving `(error, request, reply)`. Useful for third-party library error handling (e.g. SuperTokens, Passport.js). + +15. **Reply-sent short-circuit** — if `preErrorHandler` sends the reply (`reply.sent === true`), the default error handler is skipped entirely. + +16. **Silent exception suppression in `preErrorHandler`** — if `preErrorHandler` throws, the exception is caught and discarded; the default error handler always runs after. + +## Error Response Format + +17. **Consistent `ErrorResponse` shape** — every error response conforms to: + ```typescript + { + code?: string; // error code (HttpErrors / mapped CustomError: from .code; unmapped: see items 9–10) + error?: string; // HTTP status text (HttpErrors and mapped domain errors) + message: string; // error message (masked for unmapped non-HttpErrors when stackTrace: false; mapped errors use the thrown message) + name: string; // error class name (masked for unmapped non-HttpErrors when stackTrace: false; mapped errors use the thrown name) + stack?: StackTracey.Entry[] // parsed stack frames (only when stackTrace: true) + statusCode: number; // HTTP status code + } + ``` + +## Exports + +18. **`errorHandler` function** — exported standalone for use outside the plugin registration context. Pass **`stackTrace`** and optional **`domainErrorStatusMap`** through the 4th argument (`ErrorHandlerOptions`). If `domainErrorStatusMap` is absent, the domain-map branch is skipped (same as an empty map). + +19. **`CustomError` class** — base class for application errors with a `code` string field. + +20. **Type exports** — `ErrorHandlerOptions`, `ErrorHandler`, `ErrorResponse` exported for use in consuming code. + +21. **Re-exports** — `HttpErrors` (from `@fastify/sensible`) and `StackTracey` (from `stacktracey`) re-exported for convenience. diff --git a/packages/error-handler/GUIDE.md b/packages/error-handler/GUIDE.md new file mode 100644 index 000000000..40f612c1e --- /dev/null +++ b/packages/error-handler/GUIDE.md @@ -0,0 +1,307 @@ +# @prefabs.tech/fastify-error-handler — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-error-handler +``` + +```bash +pnpm add @prefabs.tech/fastify-error-handler +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-error-handler test +pnpm --filter @prefabs.tech/fastify-error-handler build +``` + +## Setup + +Register the plugin once at startup. All subsequent examples assume this setup. + +```typescript +import Fastify from "fastify"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; + +const fastify = Fastify(); + +await fastify.register(errorHandlerPlugin, { + stackTrace: false, // optional, default: false + preErrorHandler: undefined, // optional + domainErrorStatusMap: undefined, // optional: Map (e.g. new Map([["UnprocessableEntityError", 422]])) +}); +``` + +--- + +## Base Libraries + +### @fastify/sensible — Modified + +Provides HTTP error factory methods and the `HttpError` class. + +→ **Their docs:** [@fastify/sensible](https://github.com/fastify/fastify-sensible) + +`@fastify/sensible` is registered automatically with its defaults, but this package does not expose `@fastify/sensible` plugin options. + +**What we add on top:** Unified error handling for `HttpError` instances with severity-based logging, response shaping, optional stack trace output, and pre-handler interception via `preErrorHandler`. + +### stacktracey — Modified + +Parses `Error.stack` into structured frames. + +→ **Their docs:** [stacktracey](https://www.npmjs.com/package/stacktracey) + +We use `StackTracey` internally to parse stack traces and expose them in error responses as `StackTracey.Entry[]` arrays. + +**What we add on top:** `StackTracey` is re-exported from this package for use in consuming code. + +--- + +## Features + +### Global error handler + +The plugin installs a `setErrorHandler` that catches all unhandled errors thrown from routes and plugins. Non-`Error` values (strings, `null`, etc.) are coerced to `new Error("UNKNOWN_ERROR")` before processing. + +```typescript +fastify.get("/boom", async () => { + throw new Error("Something went wrong"); +}); +// → 500 response with masked message +``` + +### HttpError handling + +Errors that are `instanceof HttpError` — thrown via `fastify.httpErrors.*` — respond with the error's original status code, `error` (HTTP status text), `message`, and `name`. + +```typescript +fastify.get("/forbidden", async () => { + throw fastify.httpErrors.forbidden("You cannot access this"); + // → 403 { statusCode: 403, error: "Forbidden", message: "You cannot access this", name: "..." } +}); +``` + +### Domain error status map (`domainErrorStatusMap`) + +After `HttpError` handling, non-`HttpError` errors whose **`name`** appears as a key in the app-provided **`domainErrorStatusMap`** (`Map`) respond with the configured `statusCode` and `error` (HTTP status text), and include the thrown **`message`** and **`name`** (and **`code`** when the error is a **`CustomError`**) regardless of **`stackTrace`**; **`stackTrace: true`** only adds the parsed **`stack`** field when present. Map values must be **integers from `400` to `599`** or plugin registration throws. Logging follows the same rules as `HttpError` (5xx → `error`, 4xx → `info`, below 400 → `error`). + +**Standalone `errorHandler`:** if you reuse the exported handler without the plugin, pass **`stackTrace`** and optional **`domainErrorStatusMap`** via the handler's 4th argument (`ErrorHandlerOptions`). + +```typescript +await fastify.register(errorHandlerPlugin, { + domainErrorStatusMap: new Map([["UnprocessableEntityError", 422]]), +}); + +fastify.get("/validate", async () => { + const err = new Error("Invalid payload"); + err.name = "UnprocessableEntityError"; + throw err; +}); +``` + +### Non-HttpError handling — error masking (unmapped) + +Non-`HttpError` errors that are **not** listed in **`domainErrorStatusMap`** (plain `Error`, `CustomError`, and any subclass) respond with status `500`. When `stackTrace: false` (the default), internal details are masked: + +- `message` → `"Server error, please contact support"` +- `name` → `"Error"` +- `code` → `"INTERNAL_SERVER_ERROR"` + +For `CustomError` instances, message is replaced with `"Server has an error that is not handled, please contact support"`. + +When `stackTrace: true`, the actual `message`, `name`, and `code` are included in the response. + +### Severity-based logging for HttpErrors + +The log level depends on the error's status code: + +- `5xx` → logged at `error` level +- `4xx` → logged at `info` level +- below `400` → logged at `error` level + +Non-HttpErrors are logged at `error` level unless handled via **`domainErrorStatusMap`** (then logging follows status ranges like `HttpError`). + +### `stackTrace` option + +Controls whether parsed stack frames appear in error responses. Defaults to `false`. + +```typescript +await fastify.register(errorHandlerPlugin, { stackTrace: true }); +``` + +When enabled, error responses include a `stack` array of `StackTracey.Entry` objects (file, line, column, callee). The field is omitted if the error has no `.stack` property. + +### `preErrorHandler` option + +An optional async function called before the default error handler. Useful for intercepting errors from third-party libraries (e.g. auth middleware) before our default response logic runs. + +```typescript +await fastify.register(errorHandlerPlugin, { + preErrorHandler: async (error, request, reply) => { + if (isSupertokensError(error)) { + await SuperTokens.errorHandler()(error, request.raw, reply.raw, () => {}); + } + }, +}); +``` + +Behavior: + +- If `preErrorHandler` sends the reply (`reply.sent === true`), the default handler is skipped entirely. +- If `preErrorHandler` throws, the exception is silently discarded and the default handler still runs. + +### `ErrorResponse` JSON schema + +The plugin registers an `ErrorResponse` schema (`$id: "ErrorResponse"`) with Fastify so routes can reference it in their response schemas. + +```typescript +fastify.get("/data", { + schema: { + response: { + 400: { $ref: "ErrorResponse#" }, + 500: { $ref: "ErrorResponse#" }, + }, + }, + handler: async () => { + /* ... */ + }, +}); +``` + +### `CustomError` class + +A base class for application errors with an optional `code` string. Extends `Error` with correct prototype chain (`instanceof CustomError` and `instanceof Error` both work). + +```typescript +import { CustomError } from "@prefabs.tech/fastify-error-handler"; + +throw new CustomError("Payment failed", "PAYMENT_FAILED"); +// error.code === "PAYMENT_FAILED" +// error.name === "CustomError" +``` + +When `stackTrace: true`, the `code` and real `message` appear in the 500 response instead of generic placeholders. + +### Standalone `errorHandler` export + +The error handler function is exported for use outside the plugin — for example, to reuse in tests or to compose into a custom handler. + +```typescript +import { errorHandler } from "@prefabs.tech/fastify-error-handler"; + +fastify.setErrorHandler((error, request, reply) => { + // custom pre-processing... + return errorHandler(error, request, reply, { + stackTrace: true, + domainErrorStatusMap: new Map([["UnprocessableEntityError", 422]]), + }); +}); +``` + +### Error response format + +Every response from the error handler conforms to `ErrorResponse`: + +```typescript +type ErrorResponse = { + code?: string; // error code + error?: string; // HTTP status text (HttpErrors only) + message: string; // error message + name: string; // error class name + stack?: StackTracey.Entry[]; // parsed frames (only when stackTrace: true) + statusCode: number; // HTTP status code +}; +``` + +### Type and class exports + +| Export | Kind | Description | +| --------------------- | ----- | ------------------------------------ | +| `ErrorHandlerOptions` | type | Plugin options shape | +| `ErrorHandler` | type | Signature for `preErrorHandler` | +| `ErrorResponse` | type | Response body shape | +| `CustomError` | class | Base application error class | +| `HttpErrors` | type | Re-exported from `@fastify/sensible` | +| `StackTracey` | class | Re-exported from `stacktracey` | + +--- + +## Use Cases + +### Handling third-party auth library errors + +When using an auth library that uses its own error classes (e.g. SuperTokens), use `preErrorHandler` to intercept them before the default 500 handler fires: + +```typescript +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import supertokens from "supertokens-node"; + +await fastify.register(errorHandlerPlugin, { + preErrorHandler: async (error, request, reply) => { + if ( + supertokens.errorHandler && + supertokens.isCompatibleWithFastify(error) + ) { + await supertokens.errorHandler()(error, request.raw, reply.raw, () => {}); + } + }, +}); +``` + +### Structured application errors with codes + +Define domain-specific error subclasses using `CustomError` so that error codes survive into logs and (in dev/debug mode) into responses: + +```typescript +import { CustomError } from "@prefabs.tech/fastify-error-handler"; + +class PaymentError extends CustomError { + constructor(message: string) { + super(message, "PAYMENT_FAILED"); + } +} + +fastify.post("/pay", async () => { + throw new PaymentError("Card declined"); + // stackTrace: false → 500 with generic message + // stackTrace: true → 500 with code: "PAYMENT_FAILED", message: "Card declined" +}); +``` + +### Toggle response detail by app config + +Use an application config flag to control whether stack traces are exposed in error responses: + +```typescript +const appConfig = { exposeErrorStacks: false }; + +await fastify.register(errorHandlerPlugin, { + stackTrace: appConfig.exposeErrorStacks, +}); +``` + +### Referencing the error schema in route responses + +Reuse the registered `ErrorResponse` schema to keep your OpenAPI output consistent: + +```typescript +fastify.post("/users", { + schema: { + response: { + 201: userSchema, + 400: { $ref: "ErrorResponse#" }, + 409: { $ref: "ErrorResponse#" }, + 500: { $ref: "ErrorResponse#" }, + }, + }, + handler: async (request) => { + // ... + }, +}); +``` diff --git a/packages/error-handler/README.md b/packages/error-handler/README.md index 6d10845b1..75c7b4ad9 100644 --- a/packages/error-handler/README.md +++ b/packages/error-handler/README.md @@ -1,114 +1,134 @@ # @prefabs.tech/fastify-error-handler -A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of error handler in fastify API. +A [Fastify](https://github.com/fastify/fastify) plugin that provides a standardized, production-safe global error handler for APIs. -## Requirements +## Why This Plugin? -* [@prefabs.tech/fastify-config](../config/) -* [@fastify/sensible](https://github.com/fastify/fastify-sensible) +In a large API or microservice ecosystem, inconsistent error handling quickly leads to bloated controllers and unpredictable API responses for your frontend clients. We created this plugin to: -## Installation +- **Unify Your Error Responses**: By providing a global error formatter powered by `@fastify/sensible`, we ensure that no matter where an error originates — a database crash, a validation failure, or a manual throw — your API always responds with a standardized, predictable JSON shape. +- **Keep Controllers Clean**: We enforce an exceptions-based approach. Focus purely on the happy path in your route handlers. Instead of manually catching errors and calling `reply.code(400).send(...)`, you simply `throw` an error and let the global handler manage the rest. +- **Provide Safe Interception**: Fastify only allows one global `setErrorHandler`. If you use libraries like SuperTokens that require their own error handling, standard setups break. We designed a clean `preErrorHandler` option to let you safely run those third-party hooks before falling back to the standard global formatter. +- **Standardize Custom Exceptions**: We provide a strongly-typed `CustomError` base class so you can attach specific application error codes and metadata across your monorepo without resorting to raw strings or plain `Error` objects. -Install with npm: +## What You Get -```bash -npm install @prefabs.tech/fastify-error-handler -``` +### @fastify/sensible — Full Passthrough -Install with pnpm: +All options from [@fastify/sensible](https://www.npmjs.com/package/@fastify/sensible) are supported. This plugin registers it internally with no configuration, exposing `fastify.httpErrors.*` helpers on the instance. -```bash -pnpm add --filter "@scope/project @prefabs.tech/fastify-error-handler -``` +### Added by This Plugin + +- **Global error handler** — catches all thrown errors (HttpErrors, CustomErrors, plain Errors, and non-Error values) and formats them into a consistent `ErrorResponse` JSON shape +- **Safe message masking** — 5xx errors hide implementation details behind generic messages by default; `stackTrace: true` disables masking for development +- **`preErrorHandler` hook** — run custom logic (e.g. SuperTokens, Passport) before the default handler; short-circuits if your handler sends the reply, swallows exceptions otherwise +- **`CustomError` base class** — extend it to create domain errors with a custom `code` field; subclasses are handled safely +- **`stackTrace` option** — controls whether parsed stack frames are included in error responses +- **`ErrorResponse` JSON schema** — registered as `$id: "ErrorResponse"` for use in route response schemas via `$ref: "ErrorResponse#"` +- **Severity-aware logging** — 4xx errors log at `info`, 5xx at `error`; non-Error thrown values are normalized and logged safely +- **`domainErrorStatusMap`** — optional app-provided `Map` from `error.name` to an HTTP status integer **`400`–`599`** (invalid entries fail at registration); the plugin stores a validated copy. Mapped errors return that status with message/name (and `CustomError` `code`) in the body—only **unmapped** non-`HttpError` errors use generic masking when `stackTrace` is false -## Usage +→ [Full feature list](FEATURES.md) · [Developer guide](GUIDE.md) -### Register Plugin +## Usage Guidelines -Register @prefabs.tech/fastify-error-handler package with your Fastify instance: +### Controllers must not reply with non-200 responses -Note: Register the errorHandler plugin as early as possible (Before all your routes and plugin registration). +Do not manually send error responses from route handlers. Always `throw` and let the global error handler format the response. + +**Wrong** ```typescript -import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; -import Fastify from "fastify"; +fastify.get("/test", async (req, reply) => { + return reply.code(401).send({ message: "Unauthorized" }); +}); +``` -const start = async () => { - // Create fastify instance - const fastify = Fastify(); - - // Register fastify-error-handler plugin - await fastify.register(errorHandlerPlugin, {}); - - await fastify.listen({ - port: config.port, - host: "0.0.0.0", - }); -}; +**Correct** -start(); +```typescript +fastify.get("/test", async () => { + throw fastify.httpErrors.unauthorized("Unauthorized"); +}); ``` -### Options -#### stackTrace +### Throw `CustomError` (or a subclass) for domain errors -When enabled, the error handler will include the error’s stack trace in the HTTP response body. +Modules must throw an instance of `CustomError` (or a class extending it) for application-level errors. This ensures errors are caught consistently and the correct action can be taken. -By default, it is set to false. +```typescript +import { CustomError } from "@prefabs.tech/fastify-error-handler"; -```ts -stackTrace?: boolean; // Default: false +const file = await fileService.findById(id); +if (!file) { + throw new CustomError("File not found", "FILE_NOT_FOUND_ERROR"); +} ``` -#### preErrorHandler +## Requirements -preErrorHandler is an optional error handler that runs before the default error handler logic. -It allows you to intercept specific errors, handle them yourself, and prevent the default handler from running. +**Peer dependencies** (must be installed separately): -This is especially useful when you need to integrate with other libraries that have their own error formats — for example, handling SuperTokens errors before your API’s standard error response. +- [`fastify`](https://www.npmjs.com/package/fastify) `>=5.2.1` +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) `>=5.0.1` -```ts -preErrorHandler?: ( - error: FastifyError, - request: FastifyRequest, - reply: FastifyReply, -) => void | Promise; -``` +Register this plugin **before all routes and other plugins** so the error handler is in place for the entire application. -## Error Handling Guidelines +## Quick Start -### Controllers must not reply with non-200 responses +```typescript +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import Fastify from "fastify"; -Do not manually send error responses from controllers. +const fastify = Fastify(); -Instead, always throw an error and let the global error handler handle formatting and response. +await fastify.register(errorHandlerPlugin, { + stackTrace: process.env.NODE_ENV === "development", +}); -**Wrong** +// Throw errors in routes — the handler does the rest +fastify.get("/example", async () => { + throw fastify.httpErrors.notFound("Resource not found"); +}); -```ts -fastify.get('/test', async (req, reply) => { - return reply.code(401).send({ message: "Unauthorized" }); -}) +await fastify.listen({ port: 3000, host: "0.0.0.0" }); ``` -**Correct** +### Domain status codes -```ts -fastify.get('/test', async (req, reply) => { - throw fastify.httpErrors.unauthorized("Unauthorized"); -}) +Use **`domainErrorStatusMap`** when domain errors should return non-500 statuses — pass a **`Map`** whose keys match thrown **`error.name`**; each value must be an integer **`400`–`599`**. Mapped responses include the thrown message and name (and `CustomError` codes); generic masking applies only to **unmapped** internal errors when **`stackTrace`** is off. + +```typescript +await fastify.register(errorHandlerPlugin, { + domainErrorStatusMap: new Map([["UnprocessableEntityError", 422]]), +}); ``` -### Throw `CustomError` (or subclass) -- Modules **must throw** an instance of `CustomError` (or a class extending it). -- This ensures errors can be consistently caught and appropriate actions taken. +### Standalone `errorHandler` usage -```ts -import { CustomError } from "@prefabs.tech/fastify-error-handler"; +If you use the exported `errorHandler` directly (without registering the plugin), pass options as the 4th argument: -const file = fileService.findById(1); +```typescript +import { errorHandler } from "@prefabs.tech/fastify-error-handler"; -if (!file) { - throw new CustomError("File not found", "FILE_NOT_FOUND_ERROR"); -} +fastify.setErrorHandler((error, request, reply) => { + return errorHandler(error, request, reply, { + stackTrace: true, + domainErrorStatusMap: new Map([["UnprocessableEntityError", 422]]), + }); +}); +``` + +## Installation + +Install with npm: + +```bash +npm install @prefabs.tech/fastify-error-handler +``` + +Install with pnpm: + +```bash +pnpm add @prefabs.tech/fastify-error-handler ``` diff --git a/packages/error-handler/package.json b/packages/error-handler/package.json index e9a74c251..d6f34e5d6 100644 --- a/packages/error-handler/package.json +++ b/packages/error-handler/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-error-handler", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify error-handler plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/error-handler#readme", "repository": { @@ -31,24 +31,24 @@ }, "dependencies": { "@fastify/sensible": "6.0.4", - "stacktracey": "2.1.8" + "stacktracey": "2.2.0" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/tsconfig": "0.5.0", - "@types/node": "24.10.13", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@types/node": "24.10.15", "@types/stack-trace": "0.0.33", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "prettier": "3.8.1", + "prettier": "3.8.3", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4" }, "peerDependencies": { - "fastify": ">=5.2.1", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1" }, "engines": { diff --git a/packages/error-handler/src/__test__/domainStatusMap.test.ts b/packages/error-handler/src/__test__/domainStatusMap.test.ts new file mode 100644 index 000000000..be7aa64d3 --- /dev/null +++ b/packages/error-handler/src/__test__/domainStatusMap.test.ts @@ -0,0 +1,121 @@ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ErrorHandlerOptions } from "../types"; + +import errorHandlerPlugin, { CustomError } from "../index"; +import { FastifyInstance, makeLogSpy } from "./helpers"; + +describe("errorHandlerPlugin — domainErrorStatusMap", () => { + let fastify: FastifyInstance; + + afterEach(async () => await fastify.close()); + + it("responds with mapped status when error.name matches", async () => { + fastify = await buildFastify({ + domainErrorStatusMap: new Map([["UnprocessableEntityError", 422]]), + }); + fastify.get("/test", async () => { + const err = new Error("validation failed"); + err.name = "UnprocessableEntityError"; + throw err; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(422); + const body = res.json(); + expect(body.statusCode).toBe(422); + expect(body.message).toBe("validation failed"); + expect(body.name).toBe("UnprocessableEntityError"); + expect(body.error).toBe("Unprocessable Entity"); + expect(body.code).toBeUndefined(); + }); + + it("includes CustomError.code on mapped responses", async () => { + class UnprocessableEntityError extends CustomError { + constructor(message: string) { + super(message, "UNPROCESSABLE_ENTITY"); + this.name = "UnprocessableEntityError"; + } + } + + fastify = await buildFastify({ + domainErrorStatusMap: new Map([["UnprocessableEntityError", 422]]), + }); + fastify.get("/test", async () => { + throw new UnprocessableEntityError("bad"); + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(422); + expect(res.json().code).toBe("UNPROCESSABLE_ENTITY"); + expect(res.json().message).toBe("bad"); + }); + + it("includes stack when stackTrace is true", async () => { + fastify = await buildFastify({ + domainErrorStatusMap: new Map([["MappedError", 409]]), + stackTrace: true, + }); + fastify.get("/test", async () => { + const err = new Error("conflict"); + err.name = "MappedError"; + throw err; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().stack).toBeDefined(); + }); +}); + +describe("errorHandlerPlugin — domainErrorStatusMap logging", () => { + let fastify: FastifyInstance; + let logSpy: ReturnType; + + beforeEach(async () => { + logSpy = makeLogSpy(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fastify = Fastify({ loggerInstance: logSpy as any }); + await fastify.register(errorHandlerPlugin, { + domainErrorStatusMap: new Map([ + ["ClientErr", 422], + ["ServerMapped", 503], + ]), + }); + fastify.get("/422", async () => { + const err = new Error("x"); + err.name = "ClientErr"; + throw err; + }); + fastify.get("/503", async () => { + const err = new Error("y"); + err.name = "ServerMapped"; + throw err; + }); + await fastify.ready(); + vi.clearAllMocks(); + logSpy.child.mockImplementation(() => logSpy); + }); + + afterEach(async () => await fastify.close()); + + it("logs mapped 4xx at info", async () => { + await fastify.inject({ method: "GET", url: "/422" }); + expect(logSpy.info).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.error).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it("logs mapped 5xx at error", async () => { + await fastify.inject({ method: "GET", url: "/503" }); + expect(logSpy.error).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.info).not.toHaveBeenCalledWith(expect.any(Error)); + }); +}); + +async function buildFastify( + options: ErrorHandlerOptions, +): Promise { + const fastify = Fastify({ logger: false }); + await fastify.register(errorHandlerPlugin, options); + return fastify; +} diff --git a/packages/error-handler/src/__test__/errorHandlerExport.test.ts b/packages/error-handler/src/__test__/errorHandlerExport.test.ts new file mode 100644 index 000000000..31f8d159e --- /dev/null +++ b/packages/error-handler/src/__test__/errorHandlerExport.test.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ +import fastifySensible from "@fastify/sensible"; +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +import { errorHandler } from "../index"; + +describe("errorHandler — standalone export", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("handles errors the same way as the plugin when wired with sensible and stackTrace", async () => { + fastify = Fastify({ logger: false }); + fastify.decorate("stackTrace", false); + await fastify.register(fastifySensible); + fastify.setErrorHandler((err, request, reply) => { + errorHandler(err, request, reply); + }); + fastify.get("/boom", async () => { + throw new Error("internal detail"); + }); + await fastify.ready(); + + const res = await fastify.inject({ method: "GET", url: "/boom" }); + expect(res.statusCode).toBe(500); + expect(res.json().message).toBe("Server error, please contact support"); + expect(res.json().code).toBe("INTERNAL_SERVER_ERROR"); + }); +}); diff --git a/packages/error-handler/src/__test__/errorHandling.test.ts b/packages/error-handler/src/__test__/errorHandling.test.ts new file mode 100644 index 000000000..4c3212bcd --- /dev/null +++ b/packages/error-handler/src/__test__/errorHandling.test.ts @@ -0,0 +1,130 @@ +/* istanbul ignore file */ +import { describe, expect, it } from "vitest"; + +import { CustomError } from "../index"; +import { buildFastify } from "./helpers"; + +describe("errorHandlerPlugin — CustomError handling", () => { + it("responds with 500 for CustomError, as default if nothing handles the error", async () => { + const fastify = await buildFastify(); + fastify.get("/test", async () => { + throw new CustomError("internal failure", "MY_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + await fastify.close(); + }); + + it("sanitizes message in response when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new CustomError("secret DB credentials leaked", "MY_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).not.toContain("secret DB credentials"); + await fastify.close(); + }); + + it("sanitizes code and name in response when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new CustomError("some error", "MY_ERROR_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().code).toBe("INTERNAL_SERVER_ERROR"); + expect(res.json().name).toBe("Error"); + await fastify.close(); + }); + + it("includes code in response when stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new CustomError("some error", "MY_ERROR_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().code).toBe("MY_ERROR_CODE"); + await fastify.close(); + }); + + it("uses INTERNAL_SERVER_ERROR for CustomError when no code is set and stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new CustomError("some error"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().code).toBe("INTERNAL_SERVER_ERROR"); + await fastify.close(); + }); + + it("includes name in response when stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new CustomError("some error", "CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().name).toBe("CustomError"); + await fastify.close(); + }); + + it("CustomError subclass is handled the same way", async () => { + class DatabaseError extends CustomError { + constructor(message: string) { + super(message, "DATABASE_ERROR"); + } + } + + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new DatabaseError("connection timeout"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + expect(res.json().code).toBe("DATABASE_ERROR"); + expect(res.json().name).toBe("DatabaseError"); + await fastify.close(); + }); +}); + +describe("errorHandlerPlugin — unknown error handling", () => { + it("responds with 500 for a plain Error", async () => { + const fastify = await buildFastify(); + fastify.get("/test", async () => { + throw new Error("unexpected crash"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + await fastify.close(); + }); + + it("sanitizes the message for plain Error when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new Error("secret internal detail"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).not.toContain("secret internal detail"); + await fastify.close(); + }); + + it("sanitizes code and name for plain Error when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new Error("unexpected crash"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().code).toBe("INTERNAL_SERVER_ERROR"); + expect(res.json().name).toBe("Error"); + await fastify.close(); + }); + + it("includes stack and original message when stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw new Error("raw error message"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).toBe("raw error message"); + expect(Array.isArray(res.json().stack)).toBe(true); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/__test__/helpers.ts b/packages/error-handler/src/__test__/helpers.ts new file mode 100644 index 000000000..b9f8bba2e --- /dev/null +++ b/packages/error-handler/src/__test__/helpers.ts @@ -0,0 +1,42 @@ +import Fastify from "fastify"; +import { MockInstance, vi } from "vitest"; + +import type { ErrorHandlerOptions } from "../index"; + +import errorHandlerPlugin from "../index"; + +export type FastifyInstance = ReturnType; + +export interface LogSpy { + child: MockInstance; + debug: MockInstance; + error: MockInstance; + fatal: MockInstance; + info: MockInstance; + level: string; + silent: MockInstance; + trace: MockInstance; + warn: MockInstance; +} + +export async function buildFastify(options: ErrorHandlerOptions = {}) { + const fastify = Fastify({ logger: false }); + await fastify.register(errorHandlerPlugin, options); + return fastify; +} + +export function makeLogSpy(): LogSpy { + const spy: LogSpy = { + child: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + info: vi.fn(), + level: "trace", + silent: vi.fn(), + trace: vi.fn(), + warn: vi.fn(), + }; + spy.child.mockImplementation(() => spy); + return spy; +} diff --git a/packages/error-handler/src/__test__/httpErrors.test.ts b/packages/error-handler/src/__test__/httpErrors.test.ts new file mode 100644 index 000000000..d65899fbf --- /dev/null +++ b/packages/error-handler/src/__test__/httpErrors.test.ts @@ -0,0 +1,76 @@ +/* istanbul ignore file */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildFastify, FastifyInstance } from "./helpers"; + +describe("errorHandlerPlugin — HTTP error methods", () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = await buildFastify(); + fastify.get("/400", async () => { + throw fastify.httpErrors.badRequest("Invalid input"); + }); + fastify.get("/500", async () => { + throw fastify.httpErrors.internalServerError("Something went wrong"); + }); + }); + + afterEach(async () => await fastify.close()); + + it("badRequest → 400", async () => { + const res = await fastify.inject({ method: "GET", url: "/400" }); + expect(res.statusCode).toBe(400); + }); + + it("internalServerError → 500", async () => { + const res = await fastify.inject({ method: "GET", url: "/500" }); + expect(res.statusCode).toBe(500); + }); +}); + +describe("errorHandlerPlugin — error response structure", () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = await buildFastify(); + fastify.get("/not-found", async () => { + throw fastify.httpErrors.notFound("User not found"); + }); + }); + + afterEach(async () => await fastify.close()); + + it("includes statusCode in the response body", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().statusCode).toBe(404); + }); + + it("includes message in the response body", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().message).toBe("User not found"); + }); + + it("includes name in the response body", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(typeof res.json().name).toBe("string"); + expect(res.json().name.length).toBeGreaterThan(0); + }); + + it("includes error (HTTP status text) in the response body", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().error).toBe("Not Found"); + }); + + it("code is absent for standard httpErrors helpers", async () => { + // @fastify/sensible v6 does NOT auto-populate .code on its helpers. + // Code only appears when the HttpError has an explicit .code set. + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().code).toBeUndefined(); + }); + + it("does not include stack by default (stackTrace: false)", async () => { + const res = await fastify.inject({ method: "GET", url: "/not-found" }); + expect(res.json().stack).toBeUndefined(); + }); +}); diff --git a/packages/error-handler/src/__test__/logging.test.ts b/packages/error-handler/src/__test__/logging.test.ts new file mode 100644 index 000000000..92327b352 --- /dev/null +++ b/packages/error-handler/src/__test__/logging.test.ts @@ -0,0 +1,60 @@ +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import errorHandlerPlugin from "../index"; +import { FastifyInstance, makeLogSpy } from "./helpers"; + +describe("errorHandlerPlugin — error logging", () => { + let fastify: FastifyInstance; + let logSpy: ReturnType; + + beforeEach(async () => { + logSpy = makeLogSpy(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fastify = Fastify({ loggerInstance: logSpy as any }); + await fastify.register(errorHandlerPlugin, {}); + + fastify.get("/4xx", async () => { + throw fastify.httpErrors.badRequest("bad input"); + }); + fastify.get("/5xx", async () => { + throw fastify.httpErrors.internalServerError("server error"); + }); + fastify.get("/3xx", async () => { + const error = new Error("redirect error") as Error & { + statusCode: number; + }; + error.statusCode = 302; + Object.setPrototypeOf( + error, + fastify.httpErrors.internalServerError().constructor.prototype, + ); + throw error; + }); + + await fastify.ready(); + vi.clearAllMocks(); + logSpy.child.mockImplementation(() => logSpy); + }); + + afterEach(async () => await fastify.close()); + + it("logs 4xx errors at info level", async () => { + await fastify.inject({ method: "GET", url: "/4xx" }); + expect(logSpy.info).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.error).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it("logs 5xx errors at error level", async () => { + await fastify.inject({ method: "GET", url: "/5xx" }); + expect(logSpy.error).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.info).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it("logs sub-400 HTTP errors at error level", async () => { + await fastify.inject({ method: "GET", url: "/3xx" }); + expect(logSpy.error).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.info).not.toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/packages/error-handler/src/__test__/masking.test.ts b/packages/error-handler/src/__test__/masking.test.ts new file mode 100644 index 000000000..68260019a --- /dev/null +++ b/packages/error-handler/src/__test__/masking.test.ts @@ -0,0 +1,105 @@ +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import errorHandlerPlugin, { CustomError } from "../index"; +import { buildFastify, FastifyInstance, makeLogSpy } from "./helpers"; + +describe("errorHandlerPlugin — exact masked messages (stackTrace: false)", () => { + it("plain Error message is replaced with generic safe message", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new Error("secret details here"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).toBe("Server error, please contact support"); + await fastify.close(); + }); + + it("CustomError message is replaced with a distinct safe message", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw new CustomError("secret internal state", "MY_CODE"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().message).toBe( + "Server has an error that is not handled, please contact support", + ); + await fastify.close(); + }); +}); + +describe("errorHandlerPlugin — unknown error normalization", () => { + it("coerces a thrown non-Error value into a 500 response", async () => { + const fastify = await buildFastify(); + fastify.get("/test", async () => { + throw undefined; + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + await fastify.close(); + }); + + it("coerces a thrown string into Error UNKNOWN_ERROR before masking", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw "not an Error instance"; + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + expect(res.json().message).toBe("UNKNOWN_ERROR"); + await fastify.close(); + }); + + it("coerces a thrown null into a generic 500 when stackTrace is false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + // eslint-disable-next-line unicorn/no-null -- explicit null throw is part of the normalization behavior being tested + throw null; + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(500); + expect(res.json().message).toBe("Server error, please contact support"); + await fastify.close(); + }); +}); + +describe("errorHandlerPlugin — non-HttpError logging", () => { + let fastify: FastifyInstance; + let logSpy: ReturnType; + + beforeEach(async () => { + logSpy = makeLogSpy(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fastify = Fastify({ loggerInstance: logSpy as any }); + await fastify.register(errorHandlerPlugin, {}); + fastify.get("/test", async () => { + throw new Error("unexpected crash"); + }); + await fastify.ready(); + vi.clearAllMocks(); + logSpy.child.mockImplementation(() => logSpy); + }); + + afterEach(async () => await fastify.close()); + + it("plain Error is always logged at error level regardless of stackTrace setting", async () => { + await fastify.inject({ method: "GET", url: "/test" }); + expect(logSpy.error).toHaveBeenCalledWith(expect.any(Error)); + expect(logSpy.info).not.toHaveBeenCalledWith(expect.any(Error)); + }); +}); + +describe("errorHandlerPlugin — stack trace gated on error.stack presence", () => { + it("omits stack field when error has no .stack property, even with stackTrace: true", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + const err = new Error("no stack"); + delete err.stack; + throw err; + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().stack).toBeUndefined(); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/__test__/preErrorHandler.test.ts b/packages/error-handler/src/__test__/preErrorHandler.test.ts new file mode 100644 index 000000000..3101d3403 --- /dev/null +++ b/packages/error-handler/src/__test__/preErrorHandler.test.ts @@ -0,0 +1,94 @@ +/* istanbul ignore file */ +import { describe, expect, it, vi } from "vitest"; + +import { buildFastify } from "./helpers"; + +describe("errorHandlerPlugin — preErrorHandler", () => { + it("is called before the default error handler", async () => { + const preErrorHandler = vi.fn(); + const fastify = await buildFastify({ preErrorHandler }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.notFound("missing"); + }); + + await fastify.inject({ method: "GET", url: "/test" }); + + expect(preErrorHandler).toHaveBeenCalledOnce(); + await fastify.close(); + }); + + it("receives error, request, and reply as arguments", async () => { + let capturedArguments: unknown[] = []; + + const fastify = await buildFastify({ + preErrorHandler: async (error, request, reply) => { + capturedArguments = [error, request, reply]; + }, + }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.badRequest("bad"); + }); + + await fastify.inject({ method: "GET", url: "/test" }); + + expect(capturedArguments[0]).toBeInstanceOf(Error); + expect(capturedArguments[1]).toHaveProperty("method"); + expect(capturedArguments[2]).toHaveProperty("send"); + await fastify.close(); + }); + + it("skips default handler when preErrorHandler sends the reply", async () => { + const customPayload = { custom: "handled" }; + + const fastify = await buildFastify({ + preErrorHandler: async (_error, _request, reply) => { + await reply.code(200).send(customPayload); + }, + }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.internalServerError("boom"); + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual(customPayload); + await fastify.close(); + }); + + it("falls through to the default handler when preErrorHandler throws", async () => { + const fastify = await buildFastify({ + preErrorHandler: async () => { + throw new Error("preErrorHandler crashed"); + }, + }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.badRequest("bad input"); + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(400); + expect(res.json().message).toBe("bad input"); + await fastify.close(); + }); + + it("default handler still runs when preErrorHandler does nothing", async () => { + const fastify = await buildFastify({ + preErrorHandler: async () => { + // intentionally empty + }, + }); + + fastify.get("/test", async () => { + throw fastify.httpErrors.notFound("missing resource"); + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.statusCode).toBe(404); + expect(res.json().message).toBe("missing resource"); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/__test__/registration.test.ts b/packages/error-handler/src/__test__/registration.test.ts new file mode 100644 index 000000000..8a21c7c87 --- /dev/null +++ b/packages/error-handler/src/__test__/registration.test.ts @@ -0,0 +1,67 @@ +/* istanbul ignore file */ +import Fastify from "fastify"; +import { describe, expect, it } from "vitest"; + +import errorHandlerPlugin from "../index"; +import { buildFastify } from "./helpers"; + +describe("errorHandlerPlugin — registration", () => { + it("registers without throwing", async () => { + const fastify = Fastify({ logger: false }); + await expect( + fastify.register(errorHandlerPlugin, {}), + ).resolves.not.toThrow(); + await fastify.close(); + }); + + it("accepts stackTrace option without decorating fastify", async () => { + const fastify = await buildFastify({ stackTrace: true }); + await fastify.ready(); + expect("stackTrace" in fastify).toBe(false); + await fastify.close(); + }); + + it("accepts domainErrorStatusMap option without decorating fastify", async () => { + const fastify = await buildFastify({ + domainErrorStatusMap: new Map([["FooError", 418]]), + }); + await fastify.ready(); + expect("domainErrorStatusMap" in fastify).toBe(false); + await fastify.close(); + }); + + it("throws when domainErrorStatusMap has invalid HTTP status", async () => { + const fastify = Fastify({ logger: false }); + await expect( + fastify.register(errorHandlerPlugin, { + domainErrorStatusMap: new Map([["Bad", 99]]), + }), + ).rejects.toThrow(/domainErrorStatusMap/); + await fastify.close(); + }); + + it("throws when domainErrorStatusMap status is not an integer", async () => { + const fastify = Fastify({ logger: false }); + await expect( + fastify.register(errorHandlerPlugin, { + domainErrorStatusMap: new Map([["Bad", 422.5]]), + }), + ).rejects.toThrow(/domainErrorStatusMap/); + await fastify.close(); + }); + + it("registers the ErrorResponse JSON schema", async () => { + const fastify = await buildFastify(); + await fastify.ready(); + expect(fastify.getSchema("ErrorResponse")).toBeDefined(); + await fastify.close(); + }); + + it("registers @fastify/sensible helpers on the fastify instance", async () => { + const fastify = await buildFastify(); + await fastify.ready(); + expect(fastify.httpErrors).toBeDefined(); + expect(typeof fastify.httpErrors.badRequest).toBe("function"); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/__test__/stackTrace.test.ts b/packages/error-handler/src/__test__/stackTrace.test.ts new file mode 100644 index 000000000..147cc7e67 --- /dev/null +++ b/packages/error-handler/src/__test__/stackTrace.test.ts @@ -0,0 +1,47 @@ +/* istanbul ignore file */ +import { describe, expect, it } from "vitest"; + +import { buildFastify } from "./helpers"; + +describe("errorHandlerPlugin — stack trace option", () => { + it("omits stack from response when stackTrace: false", async () => { + const fastify = await buildFastify({ stackTrace: false }); + fastify.get("/test", async () => { + throw fastify.httpErrors.internalServerError("boom"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().stack).toBeUndefined(); + await fastify.close(); + }); + + it("includes stack array in response when stackTrace: true (5xx)", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw fastify.httpErrors.internalServerError("boom"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(Array.isArray(res.json().stack)).toBe(true); + expect(res.json().stack.length).toBeGreaterThan(0); + await fastify.close(); + }); + + it("includes stack array in response when stackTrace: true (4xx)", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw fastify.httpErrors.badRequest("bad"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(Array.isArray(res.json().stack)).toBe(true); + await fastify.close(); + }); + + it("each stack entry has file and line information", async () => { + const fastify = await buildFastify({ stackTrace: true }); + fastify.get("/test", async () => { + throw fastify.httpErrors.internalServerError("boom"); + }); + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().stack[0]).toHaveProperty("line"); + await fastify.close(); + }); +}); diff --git a/packages/error-handler/src/errorHandler.ts b/packages/error-handler/src/errorHandler.ts index de2a21c87..d0313964b 100644 --- a/packages/error-handler/src/errorHandler.ts +++ b/packages/error-handler/src/errorHandler.ts @@ -1,27 +1,66 @@ -import { STATUS_CODES } from "node:http"; - import { HttpError } from "@fastify/sensible"; import { FastifyReply, FastifyRequest } from "fastify"; +import { STATUS_CODES } from "node:http"; import StackTracey from "stacktracey"; -import { CustomError } from "./utils/error"; +import type { ErrorHandlerOptions, ErrorResponse } from "./types"; -import type { ErrorResponse } from "./types"; +import { CustomError } from "./utils/error"; const getHttpStatusText = (statusCode: number): string => STATUS_CODES[statusCode] ?? "Internal Server Error"; +function trySendDomainMappedError( + error: Error, + domainErrorStatusMap: ReadonlyMap | undefined, + reply: FastifyReply, + logger: FastifyRequest["log"], + isStackTraceEnabled: boolean, + stack: StackTracey, +): boolean { + const mappedStatusCode = domainErrorStatusMap?.get(error.name); + + if (mappedStatusCode === undefined) { + return false; + } + + if (mappedStatusCode >= 500) { + logger.error(error); + } else if (mappedStatusCode >= 400) { + logger.info(error); + } else { + logger.error(error); + } + + const response: ErrorResponse = { + code: error instanceof CustomError ? error.code : undefined, + error: getHttpStatusText(mappedStatusCode), + message: error.message, + name: error.name, + statusCode: mappedStatusCode, + }; + + if (isStackTraceEnabled && error.stack) { + response.stack = stack.items; + } + + void reply.code(mappedStatusCode).send(response); + + return true; +} + export const errorHandler = ( unknownError: unknown, request: FastifyRequest, reply: FastifyReply, + options: ErrorHandlerOptions = {}, ) => { const error = unknownError instanceof Error ? unknownError : new Error("UNKNOWN_ERROR"); const { log: logger } = request; - const isStackTraceEnabled = request.server.stackTrace || false; + const isStackTraceEnabled = options.stackTrace || false; const stack = new StackTracey(error); @@ -55,34 +94,39 @@ export const errorHandler = ( return; } - let message = "Server error, please contact support"; + if ( + trySendDomainMappedError( + error, + options.domainErrorStatusMap, + reply, + logger, + isStackTraceEnabled, + stack, + ) + ) { + return; + } + let code = "INTERNAL_SERVER_ERROR"; + let message = "Server error, please contact support"; if (error instanceof CustomError) { code = error.code || code; message = "Server has an error that is not handled, please contact support"; } - if (isStackTraceEnabled && error.stack) { - const response: ErrorResponse = { - code: code, - message: error.message, - name: error.name, - statusCode: 500, - stack: stack.items, - }; - - logger.error(error); - - void reply.code(500).send(response); + const response: ErrorResponse = { + code: isStackTraceEnabled ? code : "INTERNAL_SERVER_ERROR", + message: isStackTraceEnabled ? error.message : message, + name: isStackTraceEnabled ? error.name : "Error", + statusCode: 500, + }; - return; + if (isStackTraceEnabled && error.stack) { + response.stack = stack.items; } - // remove stack and message from error - delete error.stack; - error.message = message; + logger.error(error); - // let fastify handle the error - throw error; + void reply.code(500).send(response); }; diff --git a/packages/error-handler/src/index.ts b/packages/error-handler/src/index.ts index 791fa67d8..cd874b080 100644 --- a/packages/error-handler/src/index.ts +++ b/packages/error-handler/src/index.ts @@ -3,18 +3,17 @@ import type { HttpErrors } from "@fastify/sensible"; declare module "fastify" { interface FastifyInstance { httpErrors: HttpErrors; - stackTrace: boolean; } } +export { errorHandler } from "./errorHandler"; + export { default } from "./plugin"; -export { errorHandler } from "./errorHandler"; +export type * from "./types"; export { CustomError } from "./utils/error"; export type { HttpErrors } from "@fastify/sensible"; export { default as StackTracey } from "stacktracey"; - -export type * from "./types"; diff --git a/packages/error-handler/src/plugin.ts b/packages/error-handler/src/plugin.ts index ff3e0829d..6750549b7 100644 --- a/packages/error-handler/src/plugin.ts +++ b/packages/error-handler/src/plugin.ts @@ -1,19 +1,52 @@ +import type { FastifyInstance } from "fastify"; + import fastifySensible from "@fastify/sensible"; import FastifyPlugin from "fastify-plugin"; +import type { ErrorHandlerOptions } from "./types"; + import { errorHandler } from "./errorHandler"; import { errorSchema } from "./utils/errorSchema"; -import type { ErrorHandlerOptions } from "./types"; -import type { FastifyInstance } from "fastify"; +const DOMAIN_STATUS_MIN = 400; +const DOMAIN_STATUS_MAX = 599; + +function buildDomainErrorStatusMap( + map: ReadonlyMap | undefined, +): Map { + const result = new Map(); + + if (map === undefined) { + return result; + } + + for (const [errorName, statusCode] of map.entries()) { + if ( + typeof statusCode !== "number" || + !Number.isInteger(statusCode) || + statusCode < DOMAIN_STATUS_MIN || + statusCode > DOMAIN_STATUS_MAX + ) { + throw new Error( + `domainErrorStatusMap: invalid HTTP status for "${errorName}": ${String(statusCode)} (expected integer ${DOMAIN_STATUS_MIN}-${DOMAIN_STATUS_MAX})`, + ); + } + + result.set(errorName, statusCode); + } + + return result; +} const plugin = async ( fastify: FastifyInstance, options: ErrorHandlerOptions, ) => { fastify.log.info("Registering fastify-error-handler plugin"); - - fastify.decorate("stackTrace", options.stackTrace || false); + options.stackTrace = options.stackTrace || false; + options.domainErrorStatusMap = buildDomainErrorStatusMap( + options.domainErrorStatusMap, + ); await fastify.register(fastifySensible); @@ -30,7 +63,7 @@ const plugin = async ( } } - return errorHandler(error, request, reply); + return errorHandler(error, request, reply, options); }); fastify.addSchema(errorSchema); diff --git a/packages/error-handler/src/types.ts b/packages/error-handler/src/types.ts index 7caefe738..03fa6ab55 100644 --- a/packages/error-handler/src/types.ts +++ b/packages/error-handler/src/types.ts @@ -1,20 +1,21 @@ -import { FastifyRequest, FastifyReply } from "fastify"; +import { FastifyReply, FastifyRequest } from "fastify"; import StackTracey from "stacktracey"; type ErrorHandler = ( error: unknown, request: FastifyRequest, reply: FastifyReply, -) => void | Promise; +) => Promise | void; interface ErrorHandlerOptions { + domainErrorStatusMap?: ReadonlyMap; preErrorHandler?: ErrorHandler; stackTrace?: boolean; } type ErrorResponse = { - error?: string; code?: string; + error?: string; message: string; name: string; stack?: StackTracey.Entry[]; diff --git a/packages/error-handler/src/utils/errorSchema.ts b/packages/error-handler/src/utils/errorSchema.ts index a5711a8b4..941d6c0fa 100644 --- a/packages/error-handler/src/utils/errorSchema.ts +++ b/packages/error-handler/src/utils/errorSchema.ts @@ -1,19 +1,19 @@ export const errorSchema = { $id: "ErrorResponse", - type: "object", + additionalProperties: true, properties: { code: { type: "string" }, error: { type: "string" }, message: { type: "string" }, name: { type: "string" }, stack: { - type: "array", items: { - type: "object", additionalProperties: true, + type: "object", }, + type: "array", }, statusCode: { type: "number" }, }, - additionalProperties: true, + type: "object", }; diff --git a/packages/error-handler/tsconfig.json b/packages/error-handler/tsconfig.json index 50005d55b..1628077b9 100644 --- a/packages/error-handler/tsconfig.json +++ b/packages/error-handler/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { - "outDir": "./dist", + "baseUrl": "./", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/error-handler/vite.config.ts b/packages/error-handler/vite.config.ts index c830a4392..3373c356e 100644 --- a/packages/error-handler/vite.config.ts +++ b/packages/error-handler/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; diff --git a/packages/firebase/FEATURES.md b/packages/firebase/FEATURES.md new file mode 100644 index 000000000..052bd759a --- /dev/null +++ b/packages/firebase/FEATURES.md @@ -0,0 +1,110 @@ + + +# @prefabs.tech/fastify-firebase — Features + +## Plugin Lifecycle + +1. **Enable/disable via config flag** — when `config.firebase.enabled === false`, Firebase initialization and database migrations are skipped entirely. Routes are still registered unless individually disabled. + +2. **Automatic database migrations** — on registration (when enabled), runs `CREATE TABLE IF NOT EXISTS` for the user devices table, including a composite index on `(user_id, device_token)`. + +3. **Firebase initialization with private key normalization** — initializes `firebase-admin` using credentials from `config.firebase.credentials`, replacing literal `\n` escape sequences in `privateKey` with actual newlines before passing to the SDK. + +4. **Skip re-initialization guard** — if `admin.apps.length > 0`, `initializeFirebase` returns early without calling `admin.initializeApp` again. + +5. **Missing credentials guard** — when `enabled !== false` and `config.firebase.credentials` is absent, logs an error and returns instead of throwing. + +## Route Registration + +6. **Conditional userDevice routes** — POST `/user-device` and DELETE `/user-device` routes are registered by default; set `config.firebase.routes.userDevices.disabled = true` to skip registration entirely. + +7. **Conditional notification test route** — the test-notification route is only registered when `config.firebase.notification.test.enabled === true`. + +8. **Configurable route prefix** — all routes are registered under `config.firebase.routePrefix`. + +9. **Configurable notification test path** — the notification test route uses `config.firebase.notification.test.path` when set, falling back to the constant `/send-notification`. + +10. **Custom handler overrides** — default route handlers can be replaced per-handler via config: + ```typescript + config.firebase.handlers = { + notification: { sendNotification: myNotificationHandler }, + userDevice: { + addUserDevice: myAddHandler, + removeUserDevice: myRemoveHandler, + }, + }; + ``` + +## Middleware + +11. **`isFirebaseEnabled` preHandler** — a reusable preHandler factory that throws a `404 notFound` error when `config.firebase.enabled === false`; applied to all firebase routes automatically. + +## HTTP Route Handlers + +12. **`POST /user-device` — register a device** — requires an authenticated session (`verifySession`); throws `401 unauthorized` when `request.user` is absent; creates a device record linked to the authenticated user's ID. + +13. **`DELETE /user-device` — remove a device** — requires an authenticated session; throws `401` when unauthenticated, `404` when the user has no registered devices, `422` when the device is not owned by the requesting user. + +14. **`POST ` — test push notification** — only registered when `notification.test.enabled = true`; requires authentication; throws `422` when the receiver has no registered devices; sends a multicast FCM message with Android high-priority and APNS sound settings included. + +## Database + +15. **Configurable user devices table name** — `config.firebase.table.userDevices.name` overrides the default table name `user_devices` at both migration time and query time. + +16. **`UserDeviceService.getByUserId`** — queries all device records for a given `userId`. + +17. **`UserDeviceService.removeByDeviceToken`** — deletes a device record by token (returning the deleted row). + +18. **Device token deduplication on create** — `UserDeviceService.preCreate` calls `removeByDeviceToken` before inserting, ensuring each device token is stored only once regardless of which user previously registered it. + +## GraphQL + +19. **`firebaseSchema` export** — a merged GraphQL type-definitions document combining the base schema, notification schema, and user device schema; ready to pass to mercurius. + +20. **`sendNotification` GraphQL mutation** — `@auth`-protected; returns `401` when unauthenticated, `404` when firebase is disabled, `400` when `userId` is missing, `404` when the receiver has no registered devices, `500` on unexpected errors. + +21. **`addUserDevice` GraphQL mutation** — `@auth`-protected; returns `404` when firebase is disabled, `401` when unauthenticated, `500` on unexpected errors. + +22. **`removeUserDevice` GraphQL mutation** — `@auth`-protected; returns `404` when firebase is disabled, `401` when unauthenticated, `403` when the user has no registered devices, `403` when the device is not owned by the requesting user. + +## Utility Functions + +23. **`sendPushNotification`** — thin wrapper around `firebase-admin` `messaging().sendEachForMulticast(message)`; accepts a `MulticastMessage` and returns a promise. + +24. **`initializeFirebase`** — exported utility that initializes the `firebase-admin` app from `ApiConfig`; handles private key normalization, re-init guard, and credential-missing guard. + +## Module Augmentations + +25. **`FastifyInstance.verifySession`** — declares `verifySession` (from `supertokens-node`) on the Fastify instance interface. + +26. **`FastifyRequest.user`** — declares an optional `user: User` property on all Fastify requests. + +27. **`MercuriusContext.user`** — declares a required `user: User` property on the Mercurius context interface. + +28. **`ApiConfig.firebase`** — extends `@prefabs.tech/fastify-config`'s `ApiConfig` with the full `firebase` configuration block (credentials, enabled, handlers, notification, routePrefix, routes, table). + +## Type Exports + +29. **`User`** — `{ id: string }`. + +30. **`UserDevice`** — `{ userId, deviceToken, createdAt, updatedAt }`. + +31. **`UserDeviceCreateInput`** — partial of `UserDevice` excluding timestamps. + +32. **`UserDeviceUpdateInput`** — partial of `UserDevice` excluding timestamps and `userId`. + +33. **`TestNotificationInput`** — `{ userId, title, body, data? }`. + +## Constants + +34. **Exported route and table constants** — `ROUTE_SEND_NOTIFICATION` (`/send-notification`), `ROUTE_USER_DEVICE_ADD` (`/user-device`), `ROUTE_USER_DEVICE_REMOVE` (`/user-device`), `TABLE_USER_DEVICES` (`user_devices`). + +## Migration Queries + +35. **`createUserDevicesTableQuery` export** — exported SQL factory function for the user devices table DDL; uses the configured or default table name. + +## Initialization and Route Guards + +36. **Initialization failure logging** — if `firebase-admin` initialization throws, `initializeFirebase` catches the error and logs both a fixed message (`"Failed to initialize firebase"`) and the original error object instead of crashing startup. + +37. **Explicit notification route disable flag** — even when `config.firebase.notification.test.enabled === true`, setting `config.firebase.routes.notifications.disabled = true` prevents notification route registration entirely. diff --git a/packages/firebase/GUIDE.md b/packages/firebase/GUIDE.md new file mode 100644 index 000000000..89b6559cb --- /dev/null +++ b/packages/firebase/GUIDE.md @@ -0,0 +1,649 @@ +# @prefabs.tech/fastify-firebase — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-firebase firebase-admin fastify fastify-plugin + +# pnpm +pnpm add @prefabs.tech/fastify-firebase firebase-admin fastify fastify-plugin +``` + +Peer dependencies that must also be installed: + +```bash +pnpm add @prefabs.tech/fastify-config \ + @prefabs.tech/fastify-error-handler \ + @prefabs.tech/fastify-graphql \ + @prefabs.tech/fastify-slonik \ + mercurius \ + slonik \ + supertokens-node +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# From the repo root — install all workspaces +pnpm install + +# Run tests for this package only +pnpm --filter @prefabs.tech/fastify-firebase test + +# Build +pnpm --filter @prefabs.tech/fastify-firebase build +``` + +## Setup + +Register the plugin once. All later examples assume this configuration is in place. + +```typescript +import Fastify from "fastify"; +import firebasePlugin from "@prefabs.tech/fastify-firebase"; + +// @prefabs.tech/fastify-config, @prefabs.tech/fastify-slonik, and +// @prefabs.tech/fastify-error-handler must be registered before this plugin. +const app = Fastify(); + +await app.register(firebasePlugin); + +// The plugin reads all settings from app.config.firebase (injected by +// @prefabs.tech/fastify-config). A minimal configuration looks like: +// +// config.firebase = { +// enabled: true, +// credentials: { +// clientEmail: process.env.FIREBASE_CLIENT_EMAIL!, +// privateKey: process.env.FIREBASE_PRIVATE_KEY!, // \n escapes are normalized automatically +// projectId: process.env.FIREBASE_PROJECT_ID!, +// }, +// routePrefix: "/api", +// }; +``` + +--- + +## Base Libraries + +### `firebase-admin` — Partial Passthrough + +**Their docs:** https://www.npmjs.com/package/firebase-admin + +We initialize `firebase-admin` internally via `initializeFirebase` and expose a single wrapper (`sendPushNotification`) over `messaging().sendEachForMulticast`. The rest of the `firebase-admin` surface area (Auth, Firestore, Storage, etc.) is not wrapped; call `firebase-admin` directly in your application code for those services. + +What we add on top: + +- Private-key `\n` normalization before calling `admin.initializeApp`. +- Re-initialization guard (`admin.apps.length > 0`). +- Missing-credentials guard with structured error logging instead of a thrown exception. +- `sendPushNotification` — a typed async wrapper around multicast messaging. + +### `supertokens-node` — Partial Passthrough + +**Their docs:** https://www.npmjs.com/package/supertokens-node + +We use `verifySession` from `supertokens-node/recipe/session/framework/fastify` as a preHandler on every route. We do not wrap or re-export the supertokens initialization; you must configure SuperTokens in your application before registering this plugin. + +What we add on top: + +- `FastifyInstance.verifySession` module augmentation so the decorator is typed everywhere. +- `FastifyRequest.user` module augmentation (`{ id: string }`) populated by your application's session middleware. + +### `fastify-plugin` — Full Passthrough + +**Their docs:** https://www.npmjs.com/package/fastify-plugin + +Used internally to ensure the plugin does not create a new Fastify scope (decorators registered by peer plugins remain visible). Not re-exported. + +### `slonik` — Partial Passthrough + +**Their docs:** https://www.npmjs.com/package/slonik + +Used internally for all database access via `@prefabs.tech/fastify-slonik`'s `BaseService` / `DefaultSqlFactory`. We expose `createUserDevicesTableQuery` for consumers who manage their own migration pipeline. + +### `mercurius` — Partial Passthrough + +**Their docs:** https://www.npmjs.com/package/mercurius + +GraphQL resolvers use `mercurius.ErrorWithProps` for structured error responses and read `MercuriusContext`. We add a `MercuriusContext.user` augmentation and export `firebaseSchema`, `notificationResolver`, and `userDeviceResolver` for consumption by your Mercurius setup. + +--- + +## Features + +### 1. Enable / disable via config flag + +Set `config.firebase.enabled = false` to skip Firebase initialization and database migrations while keeping the plugin registered. Routes are still registered unless also disabled. + +```typescript +// config.firebase.enabled = false +// → initializeFirebase is NOT called +// → runMigrations is NOT called +// → routes are registered but all respond with 404 (isFirebaseEnabled preHandler) +``` + +### 2. Automatic database migrations + +On plugin registration (when `enabled !== false`) the plugin runs `CREATE TABLE IF NOT EXISTS user_devices` with a composite index on `(user_id, device_token)`. No manual migration step is needed. + +```typescript +// Nothing to call — happens automatically inside plugin registration. +// To inspect the generated SQL, import the query factory directly: +import { createUserDevicesTableQuery } from "@prefabs.tech/fastify-firebase"; + +const query = createUserDevicesTableQuery(config); +// query is a Slonik QuerySqlToken ready to execute +``` + +### 3. Firebase initialization with private key normalization + +`initializeFirebase` replaces literal `\n` in `privateKey` with actual newline characters before calling `admin.initializeApp`. This allows the raw value from an environment variable (where newlines are often stored as `\n`) to be passed directly. + +```typescript +import { initializeFirebase } from "@prefabs.tech/fastify-firebase"; + +// Called automatically by the plugin, but can also be called manually: +initializeFirebase(config, fastify); +// If admin.apps.length > 0, this is a no-op. +// If credentials are missing, logs an error and returns without throwing. +// If admin.initializeApp throws, the function logs the failure and the error object. +``` + +### 4. Skip re-initialization guard + +`initializeFirebase` checks `admin.apps.length > 0` and returns early if Firebase is already initialized. This makes the function safe to call multiple times in test environments or multi-registration scenarios. + +### 5. Missing credentials guard + +If `enabled !== false` and `config.firebase.credentials` is `undefined`, `initializeFirebase` logs `"Firebase credentials are missing"` at the error level and returns without throwing, preventing an uncaught exception during startup. + +### 6. Conditional userDevice routes + +POST `/user-device` and DELETE `/user-device` are registered by default. Disable them: + +```typescript +// In your ApiConfig: +config.firebase.routes = { + userDevices: { disabled: true }, +}; +``` + +### 7. Conditional notification test route + +The test-notification route is only registered when explicitly enabled: + +```typescript +config.firebase.notification = { + test: { + enabled: true, + path: "/test/send-notification", // optional; defaults to /send-notification + }, +}; + +// Optional hard-disable switch (wins even when test.enabled is true): +config.firebase.routes = { + notifications: { disabled: true }, +}; +``` + +### 8. Configurable route prefix + +All routes are mounted under the prefix you configure: + +```typescript +config.firebase.routePrefix = "/api/v1"; +// Results in: POST /api/v1/user-device, DELETE /api/v1/user-device, etc. +``` + +### 9. Configurable notification test path + +When the notification test route is enabled, its path defaults to `/send-notification` but can be overridden: + +```typescript +config.firebase.notification = { + test: { + enabled: true, + path: "/internal/push-test", + }, +}; +``` + +### 10. Custom handler overrides + +Replace any default route handler with your own implementation: + +```typescript +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const myAddHandler = async (request: SessionRequest, reply: FastifyReply) => { + // custom logic + reply.send({ ok: true }); +}; + +config.firebase.handlers = { + userDevice: { + addUserDevice: myAddHandler, + removeUserDevice: myRemoveHandler, + }, + notification: { + sendNotification: myNotificationHandler, + }, +}; +``` + +### 11. `isFirebaseEnabled` preHandler + +A preHandler factory that throws a Fastify `404 notFound` error if `config.firebase.enabled === false`. It is applied automatically to all firebase routes by this package. + +```typescript +// No extra setup required. Once the plugin is registered: +config.firebase.enabled = false; + +// Firebase-managed routes then return 404: +// POST /user-device +// DELETE /user-device +// POST /send-notification (when enabled in config) +``` + +### 12. `POST /user-device` — register a device token + +Requires a valid SuperTokens session. Associates the authenticated user's ID with a device token. Deduplicates automatically — if the token is already registered to another user, it is removed first. + +```typescript +// POST /user-device +// Headers: Cookie: sAccessToken=... +// Body: +{ "deviceToken": "fcm-token-abc123" } + +// 200 response: +{ + "userId": "user-uuid", + "deviceToken": "fcm-token-abc123", + "createdAt": 1712345678, + "updatedAt": 1712345678 +} +// 401 — unauthenticated +// 404 — firebase disabled +``` + +### 13. `DELETE /user-device` — remove a device token + +Requires authentication. Validates that the device token belongs to the requesting user before deleting. + +```typescript +// DELETE /user-device +// Headers: Cookie: sAccessToken=... +// Body: +{ "deviceToken": "fcm-token-abc123" } + +// 200 — returns the deleted UserDevice record (or null) +// 401 — unauthenticated +// 404 — user has no registered devices +// 422 — device not owned by requesting user +// 404 — firebase disabled +``` + +### 14. `POST ` — test push notification + +Only registered when `config.firebase.notification.test.enabled = true`. Sends a multicast FCM message with Android high-priority and APNS default-sound settings to all devices registered for the target user. + +```typescript +// POST /send-notification (or your configured test path) +// Headers: Cookie: sAccessToken=... +// Body: +{ + "userId": "target-user-uuid", + "title": "Hello", + "message": "World", +} + +// 200: { "message": "Notification sent successfully" } +// 401 — unauthenticated +// 422 — receiver has no registered devices +// 404 — firebase disabled +``` + +### 15. Configurable user devices table name + +Override the default `user_devices` table name for both migrations and all queries: + +```typescript +config.firebase.table = { + userDevices: { name: "custom_user_devices" }, +}; +``` + +### 16 & 17. `UserDeviceService.getByUserId` / `removeByDeviceToken` + +```typescript +import { UserDeviceService } from "@prefabs.tech/fastify-firebase"; + +const service = new UserDeviceService(config, database, dbSchema); + +// Get all devices for a user +const devices = await service.getByUserId("user-uuid"); +// → UserDevice[] | undefined + +// Remove a device by token (returns the deleted row) +const removed = await service.removeByDeviceToken("fcm-token-abc123"); +// → UserDevice | undefined +``` + +### 18. Device token deduplication on create + +`UserDeviceService.create` (inherited from `BaseService`) calls `preCreate` before inserting. `preCreate` removes any existing row with the same `deviceToken`. This means each FCM token can only be associated with one user at a time. + +```typescript +const service = new UserDeviceService(config, database, dbSchema); + +// If "fcm-token-abc" is already registered to user A, this call first +// deletes that row then inserts a new one for user B: +await service.create({ deviceToken: "fcm-token-abc", userId: "user-b" }); +``` + +### 19. `firebaseSchema` export + +A merged GraphQL SDL document combining the base schema, notification types, and user device types. Pass it to Mercurius alongside your resolvers. + +```typescript +import { + firebaseSchema, + notificationResolver, + userDeviceResolver, +} from "@prefabs.tech/fastify-firebase"; +import { mergeResolvers } from "@prefabs.tech/fastify-graphql"; + +// In your Mercurius setup: +await app.register(mercurius, { + schema: firebaseSchema, + resolvers: mergeResolvers([notificationResolver, userDeviceResolver]), +}); +``` + +### 20. `sendNotification` GraphQL mutation + +```graphql +mutation { + sendNotification( + data: { + userId: "target-user-uuid" + title: "New message" + body: "You have a new message" + } + ) { + message + } +} +``` + +Error codes returned as `mercurius.ErrorWithProps`: + +- `401` — unauthenticated (`context.user` is absent) +- `404` — firebase disabled +- `400` — `userId` not provided +- `404` — receiver has no registered devices +- `500` — unexpected error + +### 21. `addUserDevice` GraphQL mutation + +```graphql +mutation { + addUserDevice(data: { deviceToken: "fcm-token-xyz" }) { + id + userId + deviceToken + createdAt + updatedAt + } +} +``` + +Error codes: `404` firebase disabled, `401` unauthenticated, `500` unexpected. + +### 22. `removeUserDevice` GraphQL mutation + +```graphql +mutation { + removeUserDevice(data: { deviceToken: "fcm-token-xyz" }) { + id + userId + deviceToken + } +} +``` + +Error codes: `404` firebase disabled, `401` unauthenticated, `403` user has no devices, `403` device not owned by user. + +### 23. `sendPushNotification` utility + +Sends a Firebase multicast message. Accepts the full `MulticastMessage` type from `firebase-admin`. + +```typescript +import { sendPushNotification } from "@prefabs.tech/fastify-firebase"; +import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; + +const message: MulticastMessage = { + tokens: ["token-a", "token-b"], + notification: { title: "Alert", body: "Something happened" }, + data: { orderId: "42" }, +}; + +await sendPushNotification(message); +``` + +### 24. `initializeFirebase` utility + +```typescript +import { initializeFirebase } from "@prefabs.tech/fastify-firebase"; + +initializeFirebase(config, fastify); +// No-op if already initialized. +// Logs error (does not throw) if credentials are missing. +``` + +### 25–28. Module augmentations + +The package extends four interfaces automatically on import. No action needed — these give you type safety throughout your application: + +```typescript +import "@prefabs.tech/fastify-firebase"; // augmentations applied on import + +// fastify.verifySession is now typed +// request.user is now typed as User | undefined +// MercuriusContext.user is now typed as User +// ApiConfig.firebase is now typed with all config options +``` + +### 29–33. Type exports + +```typescript +import type { + User, + UserDevice, + UserDeviceCreateInput, + UserDeviceUpdateInput, + TestNotificationInput, +} from "@prefabs.tech/fastify-firebase"; +``` + +| Type | Shape | +| ----------------------- | ------------------------------------------------------------------- | +| `User` | `{ id: string }` | +| `UserDevice` | `{ userId, deviceToken, createdAt: number, updatedAt: number }` | +| `UserDeviceCreateInput` | `Partial>` | +| `UserDeviceUpdateInput` | `Partial>` | +| `TestNotificationInput` | `{ userId, title, body, data?: Record }` | + +### 34. Route and table constants + +```typescript +import { + ROUTE_SEND_NOTIFICATION, // "/send-notification" + ROUTE_USER_DEVICE_ADD, // "/user-device" + ROUTE_USER_DEVICE_REMOVE, // "/user-device" + TABLE_USER_DEVICES, // "user_devices" +} from "@prefabs.tech/fastify-firebase"; +``` + +### 35. `createUserDevicesTableQuery` export + +```typescript +import { createUserDevicesTableQuery } from "@prefabs.tech/fastify-firebase"; + +const query = createUserDevicesTableQuery(config); +// Returns a Slonik QuerySqlToken for the user_devices DDL. +// Respects config.firebase.table?.userDevices?.name. +``` + +--- + +## Use Cases + +### Use case 1: Register the plugin and enable FCM push notifications + +Full end-to-end setup for a Fastify app that accepts device registrations and sends notifications via REST. + +```typescript +import Fastify from "fastify"; +import configPlugin from "@prefabs.tech/fastify-config"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import firebasePlugin from "@prefabs.tech/fastify-firebase"; + +const app = Fastify({ logger: true }); + +await app.register(configPlugin); +await app.register(errorHandlerPlugin); +await app.register(slonikPlugin); +await app.register(firebasePlugin); + +// config.firebase is sourced from ApiConfig (e.g. env vars via @prefabs.tech/fastify-config): +// { +// enabled: true, +// credentials: { +// clientEmail: "svc@project.iam.gserviceaccount.com", +// privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", +// projectId: "my-firebase-project", +// }, +// routePrefix: "/api", +// } + +await app.listen({ port: 3000 }); +// Routes now active: +// POST /api/user-device +// DELETE /api/user-device +``` + +### Use case 2: Enable GraphQL mutations for notifications and device management + +```typescript +import mercurius from "mercurius"; +import { + firebaseSchema, + notificationResolver, + userDeviceResolver, +} from "@prefabs.tech/fastify-firebase"; +import { mergeResolvers } from "@prefabs.tech/fastify-graphql"; + +await app.register(mercurius, { + schema: firebaseSchema, + resolvers: mergeResolvers([notificationResolver, userDeviceResolver]), + context: (request) => ({ + user: request.user, // populated by your session middleware + config: app.config, + database: app.slonik, + dbSchema: app.dbSchema, + app, + }), +}); +``` + +### Use case 3: Send a push notification from application code + +```typescript +import { + UserDeviceService, + sendPushNotification, +} from "@prefabs.tech/fastify-firebase"; +import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; + +async function notifyUser( + config: ApiConfig, + database: Database, + dbSchema: string, + userId: string, + title: string, + body: string, +) { + const service = new UserDeviceService(config, database, dbSchema); + const devices = await service.getByUserId(userId); + + if (!devices || devices.length === 0) { + return; // user has no registered devices + } + + const message: MulticastMessage = { + tokens: devices.map((d) => d.deviceToken), + notification: { title, body }, + }; + + await sendPushNotification(message); +} +``` + +### Use case 4: Disable specific routes and override a handler + +```typescript +// Disable device routes; use only GraphQL for device management. +// Override the notification handler with custom logic. +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +const customSendNotification = async ( + request: SessionRequest, + reply: FastifyReply, +) => { + // custom auditing, rate limiting, etc. + const { userId, title, message } = request.body as { + userId: string; + title: string; + message: string; + }; + // ... custom logic ... + reply.send({ success: true, message: "sent" }); +}; + +// In your ApiConfig: +// config.firebase.routes = { userDevices: { disabled: true } }; +// config.firebase.handlers = { notification: { sendNotification: customSendNotification } }; +``` + +### Use case 5: Run with Firebase disabled (feature flag) + +Keep the plugin registered but prevent all Firebase activity (useful for environments without Firebase credentials): + +```typescript +// config.firebase.enabled = false + +// Result: +// - initializeFirebase → skipped (no credentials needed) +// - runMigrations → skipped +// - All firebase routes are registered but respond with 404 +// - GraphQL mutations return 404 +// - sendPushNotification will fail if called directly (no firebase app) +``` + +### Use case 6: Use a custom table name + +```typescript +// config.firebase.table = { userDevices: { name: "mobile_push_devices" } }; +// - Migration creates "mobile_push_devices" table (not "user_devices") +// - All UserDeviceService queries target "mobile_push_devices" +// - ROUTE_USER_DEVICE_ADD / TABLE_USER_DEVICES constants are unaffected +// (they reflect defaults; the runtime table name comes from config) +``` diff --git a/packages/firebase/README.md b/packages/firebase/README.md index 989758d41..996312c1b 100644 --- a/packages/firebase/README.md +++ b/packages/firebase/README.md @@ -2,23 +2,41 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of Firebase Admin in a fastify API. +## Why this plugin? + +Integrating Firebase Admin into a Node.js API typically involves much more than just calling `initializeApp()`. To support features like push notifications securely, you must manage user device tokens in a database, expose REST routes or GraphQL mutations to clients to register those devices, and define secure dispatch handlers. We created this plugin to: + +- **Provide a Complete Feature Slice**: Rather than just wrapping the SDK, this plugin provides a fully functioning User Device and Notification management system out-of-the-box, automatically taking advantage of your `@prefabs.tech/fastify-slonik` database setup. +- **Bootstrap APIs Instantly**: It automatically provides and wires up both REST routes and GraphQL resolvers/schemas (`userDevice`, `notification`) so you don't have to manually write the boilerplate to add, remove, and manage FCM tokens across your applications. +- **Centralize Configuration**: By extending our `@prefabs.tech/fastify-config` interface, we ensure that your Firebase credentials, database table preferences, and route configurations are strictly typed and managed in one central place alongside the rest of your app. +- **Allow Clean Overrides**: While we provide default controllers and services for handling devices and notifications, the plugin architecture allows you to easily override them via the config (`config.firebase.handlers`) whenever your business logic requires custom behavior. + ## Requirements -* [@prefabs.tech/fastify-config](../config/) -* [@prefabs.tech/fastify-slonik](../slonik/) +Peer dependencies (install compatible versions — see [package.json](./package.json)): + +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-error-handler](../error-handler/) +- [@prefabs.tech/fastify-graphql](../graphql/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [`fastify`](https://www.npmjs.com/package/fastify) +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) +- [`mercurius`](https://www.npmjs.com/package/mercurius) +- [`slonik`](https://www.npmjs.com/package/slonik) +- [`supertokens-node`](https://www.npmjs.com/package/supertokens-node) ## Installation Install with npm: ```bash -npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik @prefabs.tech/fastify-firebase +npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-graphql @prefabs.tech/fastify-slonik @prefabs.tech/fastify-firebase fastify fastify-plugin mercurius slonik supertokens-node ``` Install with pnpm: ```bash -pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik @prefabs.tech/fastify-firebase +pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-graphql @prefabs.tech/fastify-slonik @prefabs.tech/fastify-firebase fastify fastify-plugin mercurius slonik supertokens-node ``` ## Usage @@ -28,32 +46,35 @@ pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fa Register the fastify-firebase plugin with your Fastify instance: ```typescript -import firebasePlugin from "@prefabs.tech/fastify-firebase"; import configPlugin from "@prefabs.tech/fastify-config"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import firebasePlugin from "@prefabs.tech/fastify-firebase"; +import graphqlPlugin from "@prefabs.tech/fastify-graphql"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; import Fastify from "fastify"; import config from "./config"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; -import type { FastifyInstance } from "fastify"; const start = async () => { - // Create fastify instance const fastify = Fastify({ logger: config.logger, }); - // Register fastify-config plugin await fastify.register(configPlugin, { config }); - - // Register fastify-firebase plugin + await fastify.register(errorHandlerPlugin, { + stackTrace: process.env.NODE_ENV === "development", + }); + await fastify.register(slonikPlugin, config.slonik); + await fastify.register(graphqlPlugin, config.graphql); await fastify.register(firebasePlugin); await fastify.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); -} +}; start(); ``` @@ -107,7 +128,7 @@ To load and merge this schema with your application's custom schemas, update you ```typescript import { firebaseSchema } from "@prefabs.tech/fastify-firebase"; import { loadFilesSync } from "@graphql-tools/load-files"; -import { mergeTypeDefs } from ""; +import { mergeTypeDefs } from "@graphql-tools/merge"; import { makeExecutableSchema } from "@graphql-tools/schema"; const schemas: string[] = loadFilesSync("./src/**/*.gql"); @@ -123,7 +144,10 @@ export default schema; To integrate the resolvers provided by this package, import them and merge with your application's resolvers: ```typescript -import { notificationResolver, userDeviceResolver } from "@prefabs.tech/fastify-firebase"; +import { + notificationResolver, + userDeviceResolver, +} from "@prefabs.tech/fastify-firebase"; import type { IResolvers } from "mercurius"; diff --git a/packages/firebase/__test__/service.spec.ts b/packages/firebase/__test__/service.spec.ts deleted file mode 100644 index b714c1eca..000000000 --- a/packages/firebase/__test__/service.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* istanbul ignore file */ -import { describe, expect, it } from "vitest"; - -describe("Firebase Service", () => { - // TODO: Replace placeholder test - it("Should create an firebase admin instance", () => { - expect(false).toBe(false); - }); -}); diff --git a/packages/firebase/package.json b/packages/firebase/package.json index b8c1fc10e..11f4a1d05 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-firebase", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify firebase plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/firebase#readme", "repository": { @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-firebase.cjs", "module": "./dist/prefabs-tech-fastify-firebase.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -29,42 +31,43 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { - "firebase-admin": "13.6.1", + "firebase-admin": "13.8.0", "zod": "3.25.76" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/fastify-error-handler": "0.93.5", - "@prefabs.tech/fastify-graphql": "0.93.5", - "@prefabs.tech/fastify-slonik": "0.93.5", - "@prefabs.tech/tsconfig": "0.5.0", - "@types/node": "24.10.13", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/fastify-error-handler": "0.94.0", + "@prefabs.tech/fastify-graphql": "0.94.0", + "@prefabs.tech/fastify-slonik": "0.94.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@types/node": "24.10.15", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "graphql": "16.12.0", - "mercurius": "16.7.0", - "prettier": "3.8.1", + "graphql": "16.13.2", + "mercurius": "16.9.0", + "pg-mem": "3.0.14", + "prettier": "3.8.3", "slonik": "46.8.0", "supertokens-node": "14.1.4", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/fastify-error-handler": "0.93.5", - "@prefabs.tech/fastify-graphql": "0.93.5", - "@prefabs.tech/fastify-slonik": "0.93.5", - "fastify": ">=5.2.1", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/fastify-error-handler": "0.94.0", + "@prefabs.tech/fastify-graphql": "0.94.0", + "@prefabs.tech/fastify-slonik": "0.94.0", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1", "mercurius": ">=16.1.0", "slonik": ">=46.1.0", - "supertokens-node": ">=14.1.3" + "supertokens-node": ">=14.1.4" }, "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/firebase/src/__test__/controllers.test.ts b/packages/firebase/src/__test__/controllers.test.ts new file mode 100644 index 000000000..e6172efeb --- /dev/null +++ b/packages/firebase/src/__test__/controllers.test.ts @@ -0,0 +1,134 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ROUTE_SEND_NOTIFICATION, + ROUTE_USER_DEVICE_ADD, + ROUTE_USER_DEVICE_REMOVE, +} from "../constants"; + +// The route schemas reference "ErrorResponse#" which is registered by the error-handler plugin. +// We add it directly here so the test instance can resolve the $ref. +const errorResponseSchema = { + $id: "ErrorResponse", + additionalProperties: true, + properties: { + code: { type: "string" }, + error: { type: "string" }, + message: { type: "string" }, + statusCode: { type: "number" }, + }, + type: "object", +}; + +const mockVerifySession = async () => {}; + +const buildFastify = (firebaseConfig: Record = {}) => { + const fastify = Fastify({ logger: false }); + + fastify.addSchema(errorResponseSchema); + fastify.decorate("config", { + firebase: { + enabled: true, + ...firebaseConfig, + }, + }); + fastify.decorate("verifySession", () => mockVerifySession); + fastify.decorate("httpErrors", { + notFound: (message: string) => + Object.assign(new Error(message), { statusCode: 404 }), + unauthorized: (message: string) => + Object.assign(new Error(message), { statusCode: 401 }), + }); + + return fastify; +}; + +describe("notification controller — custom handler overrides", async () => { + const { default: controller } = + await import("../model/notification/controller"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls custom sendNotification handler from config.firebase.handlers.notification", async () => { + const customHandler = vi.fn().mockImplementation(async (_req, reply) => { + await reply.send({ ok: true }); + }); + + fastify = buildFastify({ + handlers: { notification: { sendNotification: customHandler } }, + notification: { test: { enabled: true, path: ROUTE_SEND_NOTIFICATION } }, + }); + await fastify.register(controller); + await fastify.ready(); + + await fastify.inject({ + method: "POST", + payload: { message: "Hello", title: "Test", userId: "user-1" }, + url: ROUTE_SEND_NOTIFICATION, + }); + + expect(customHandler).toHaveBeenCalled(); + await fastify.close(); + }); +}); + +describe("userDevice controller — custom handler overrides", async () => { + const { default: controller } = + await import("../model/userDevice/controller"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls custom addUserDevice handler from config.firebase.handlers.userDevice", async () => { + const customHandler = vi.fn().mockImplementation(async (_req, reply) => { + await reply.send({ ok: true }); + }); + + fastify = buildFastify({ + handlers: { userDevice: { addUserDevice: customHandler } }, + }); + await fastify.register(controller); + await fastify.ready(); + + await fastify.inject({ + method: "POST", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_ADD, + }); + + expect(customHandler).toHaveBeenCalled(); + await fastify.close(); + }); + + it("calls custom removeUserDevice handler from config.firebase.handlers.userDevice", async () => { + const customHandler = vi.fn().mockImplementation(async (_req, reply) => { + await reply.send({ ok: true }); + }); + + fastify = buildFastify({ + handlers: { userDevice: { removeUserDevice: customHandler } }, + }); + await fastify.register(controller); + await fastify.ready(); + + await fastify.inject({ + method: "DELETE", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_REMOVE, + }); + + expect(customHandler).toHaveBeenCalled(); + await fastify.close(); + }); +}); diff --git a/packages/firebase/src/__test__/handlers.test.ts b/packages/firebase/src/__test__/handlers.test.ts new file mode 100644 index 000000000..3338f63b0 --- /dev/null +++ b/packages/firebase/src/__test__/handlers.test.ts @@ -0,0 +1,288 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ROUTE_SEND_NOTIFICATION, + ROUTE_USER_DEVICE_ADD, + ROUTE_USER_DEVICE_REMOVE, +} from "../constants"; +import notificationController from "../model/notification/controller"; +import userDeviceController from "../model/userDevice/controller"; + +const { + mockCreate, + mockGetByUserId, + mockRemoveByDeviceToken, + sendPushNotificationMock, +} = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockGetByUserId: vi.fn(), + mockRemoveByDeviceToken: vi.fn(), + sendPushNotificationMock: vi.fn(), +})); + +vi.mock("../model/userDevice/service", () => ({ + default: vi.fn().mockImplementation(() => ({ + create: mockCreate, + getByUserId: mockGetByUserId, + removeByDeviceToken: mockRemoveByDeviceToken, + })), +})); + +vi.mock("../lib/sendPushNotification", () => ({ + default: sendPushNotificationMock, +})); + +const errorResponseSchema = { + $id: "ErrorResponse", + additionalProperties: true, + properties: { + code: { type: "string" }, + error: { type: "string" }, + message: { type: "string" }, + statusCode: { type: "number" }, + }, + type: "object", +}; + +type VerifySessionRequest = { + headers: { "x-user-id"?: string }; + user?: object; +}; + +const verifySession = async (request: VerifySessionRequest) => { + if (request.headers["x-user-id"]) { + request.user = { id: request.headers["x-user-id"] }; + } +}; + +const buildFastify = (firebaseConfig: Record = {}) => { + const fastify = Fastify({ logger: false }); + + fastify.addSchema(errorResponseSchema); + fastify.decorate("config", { + firebase: { + enabled: true, + notification: { + test: { + enabled: true, + path: ROUTE_SEND_NOTIFICATION, + }, + }, + ...firebaseConfig, + }, + }); + fastify.decorate("dbSchema", "public"); + fastify.decorate("slonik", { connect: vi.fn(), pool: {}, query: vi.fn() }); + fastify.decorate("httpErrors", { + notFound: (message: string) => + Object.assign(new Error(message), { statusCode: 404 }), + unauthorized: (message: string) => + Object.assign(new Error(message), { statusCode: 401 }), + unprocessableEntity: (message: string) => + Object.assign(new Error(message), { statusCode: 422 }), + }); + fastify.decorate("verifySession", () => verifySession); + + return fastify; +}; + +describe("firebase route handlers", () => { + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (fastify) { + await fastify.close(); + } + }); + + it("returns 401 for POST /user-device when request.user is missing", async () => { + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + method: "POST", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_ADD, + }); + + expect(response.statusCode).toBe(401); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("creates a user-device record for authenticated POST /user-device", async () => { + mockCreate.mockResolvedValue({ + createdAt: 1, + deviceToken: "token-abc", + updatedAt: 1, + userId: "user-1", + }); + + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "user-1" }, + method: "POST", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_ADD, + }); + + expect(response.statusCode).toBe(200); + expect(mockCreate).toHaveBeenCalledWith({ + deviceToken: "token-abc", + userId: "user-1", + }); + }); + + it("returns 404 for DELETE /user-device when user has no devices", async () => { + mockGetByUserId.mockResolvedValue([]); + + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "user-1" }, + method: "DELETE", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_REMOVE, + }); + + expect(response.statusCode).toBe(404); + expect(mockRemoveByDeviceToken).not.toHaveBeenCalled(); + }); + + it("returns 422 for DELETE /user-device when token is not owned by user", async () => { + mockGetByUserId.mockResolvedValue([ + { + deviceToken: "different-token", + userId: "user-1", + }, + ]); + + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "user-1" }, + method: "DELETE", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_REMOVE, + }); + + expect(response.statusCode).toBe(422); + expect(mockRemoveByDeviceToken).not.toHaveBeenCalled(); + }); + + it("deletes device for authenticated DELETE /user-device when token is owned", async () => { + mockGetByUserId.mockResolvedValue([ + { + deviceToken: "token-abc", + userId: "user-1", + }, + ]); + mockRemoveByDeviceToken.mockResolvedValue({ + createdAt: 1, + deviceToken: "token-abc", + updatedAt: 1, + userId: "user-1", + }); + + fastify = buildFastify(); + await fastify.register(userDeviceController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "user-1" }, + method: "DELETE", + payload: { deviceToken: "token-abc" }, + url: ROUTE_USER_DEVICE_REMOVE, + }); + + expect(response.statusCode).toBe(200); + expect(mockRemoveByDeviceToken).toHaveBeenCalledWith("token-abc"); + }); + + it("returns 422 for POST /send-notification when receiver has no devices", async () => { + mockGetByUserId.mockResolvedValue([]); + + fastify = buildFastify(); + await fastify.register(notificationController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "sender-1" }, + method: "POST", + payload: { message: "Body", title: "Title", userId: "receiver-1" }, + url: ROUTE_SEND_NOTIFICATION, + }); + + expect(response.statusCode).toBe(422); + expect(sendPushNotificationMock).not.toHaveBeenCalled(); + }); + + it("sends push notification with android/apns defaults for valid POST /send-notification", async () => { + mockGetByUserId.mockResolvedValue([ + { + deviceToken: "token-a", + userId: "receiver-1", + }, + { + deviceToken: "token-b", + userId: "receiver-1", + }, + ]); + sendPushNotificationMock.mockResolvedValue(); + + fastify = buildFastify(); + await fastify.register(notificationController); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "x-user-id": "sender-1" }, + method: "POST", + payload: { + body: "Body", + data: { orderId: "42" }, + message: "Body", + title: "Title", + userId: "receiver-1", + }, + url: ROUTE_SEND_NOTIFICATION, + }); + + expect(response.statusCode).toBe(200); + expect(sendPushNotificationMock).toHaveBeenCalledWith({ + android: { + notification: { sound: "default" }, + priority: "high", + }, + apns: { + payload: { + aps: { sound: "default" }, + }, + }, + data: { + body: "Body", + orderId: "42", + title: "Title", + }, + notification: { + body: "Body", + title: "Title", + }, + tokens: ["token-a", "token-b"], + }); + }); +}); diff --git a/packages/firebase/src/__test__/helpers/createConfig.ts b/packages/firebase/src/__test__/helpers/createConfig.ts new file mode 100644 index 000000000..6d4ccd0f9 --- /dev/null +++ b/packages/firebase/src/__test__/helpers/createConfig.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +const createConfig = ( + firebaseOverrides: Record = {}, +): ApiConfig => + ({ + appName: "app", + appOrigin: ["http://localhost"], + baseUrl: "http://localhost", + env: "development", + firebase: { + enabled: true, + ...firebaseOverrides, + }, + logger: { level: "debug" }, + name: "Test", + port: 3000, + protocol: "http", + rest: { enabled: true }, + slonik: { + db: { + databaseName: "test", + host: "localhost", + password: "password", + username: "username", + }, + }, + version: "0.1", + }) as unknown as ApiConfig; + +export default createConfig; diff --git a/packages/firebase/src/__test__/helpers/createDatabase.ts b/packages/firebase/src/__test__/helpers/createDatabase.ts new file mode 100644 index 000000000..33997794b --- /dev/null +++ b/packages/firebase/src/__test__/helpers/createDatabase.ts @@ -0,0 +1,56 @@ +import type { IMemoryDb, SlonikAdapterOptions } from "pg-mem"; +import type { Interceptor, QueryResultRow } from "slonik"; + +/* istanbul ignore file */ +import { newDb } from "pg-mem"; + +// Converts snake_case keys to camelCase — mirrors fieldNameCaseConverter from @prefabs.tech/fastify-slonik +const snakeToCamel = (s: string) => + s.replaceAll(/_([a-z])/g, (_, c: string) => c.toUpperCase()); + +const camelizeRow = (row: QueryResultRow): QueryResultRow => { + const result: QueryResultRow = {}; + for (const [key, value] of Object.entries(row)) { + result[snakeToCamel(key)] = value; + } + return result; +}; + +const fieldNameCaseConverter: Interceptor = { + transformRow: (_queryContext, _query, row): QueryResultRow => + camelizeRow(row), +}; + +interface Options { + db?: IMemoryDb; + slonikAdapterOptions?: SlonikAdapterOptions; +} + +const createDatabase = async (options?: Options) => { + const db = options?.db ?? newDb(); + + const defaultOptions: SlonikAdapterOptions = { + createPoolOptions: { + interceptors: [fieldNameCaseConverter], + }, + }; + + const mergedOptions: SlonikAdapterOptions = { + ...defaultOptions, + ...options?.slonikAdapterOptions, + createPoolOptions: { + ...defaultOptions.createPoolOptions, + ...options?.slonikAdapterOptions?.createPoolOptions, + }, + }; + + const pool = await db.adapters.createSlonik(mergedOptions); + + return { + connect: pool.connect.bind(pool), + pool, + query: pool.query.bind(pool), + }; +}; + +export default createDatabase; diff --git a/packages/firebase/src/__test__/initializeFirebase.test.ts b/packages/firebase/src/__test__/initializeFirebase.test.ts new file mode 100644 index 000000000..94a6b96d2 --- /dev/null +++ b/packages/firebase/src/__test__/initializeFirebase.test.ts @@ -0,0 +1,130 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// vi.hoisted ensures these are available inside the vi.mock factory +const { mockApps, mockCert, mockInitializeApp } = vi.hoisted(() => ({ + mockApps: [] as unknown[], + mockCert: vi.fn().mockReturnValue({ type: "service_account" }), + mockInitializeApp: vi.fn(), +})); + +vi.mock("firebase-admin", () => ({ + default: { + get apps() { + return mockApps; + }, + credential: { + cert: mockCert, + }, + initializeApp: mockInitializeApp, + }, +})); + +const baseCredentials = { + clientEmail: "test@test.iam.gserviceaccount.com", + privateKey: String.raw`-----BEGIN PRIVATE KEY-----\nKEY_DATA\nMORE_DATA\n-----END PRIVATE KEY-----`, + projectId: "test-project", +}; + +const makeConfig = (overrides: object = {}): ApiConfig => + ({ + firebase: { + credentials: baseCredentials, + enabled: true, + ...overrides, + }, + }) as unknown as ApiConfig; + +const makeFastify = () => + ({ + log: { + error: vi.fn(), + info: vi.fn(), + }, + }) as unknown as FastifyInstance; + +describe("initializeFirebase", async () => { + const { default: initializeFirebase } = + await import("../lib/initializeFirebase"); + + beforeEach(() => { + vi.clearAllMocks(); + mockApps.length = 0; + }); + + afterEach(() => { + mockApps.length = 0; + }); + + it("skips initialization when admin.apps is already populated", async () => { + mockApps.push({}); + const fastify = makeFastify(); + const config = makeConfig(); + + initializeFirebase(config, fastify); + + expect(mockInitializeApp).not.toHaveBeenCalled(); + }); + + it("logs an error when enabled is not false but credentials are missing", () => { + const fastify = makeFastify(); + const config = makeConfig({ credentials: undefined }); + + initializeFirebase(config, fastify); + + expect(fastify.log.error).toHaveBeenCalledWith( + "Firebase credentials are missing", + ); + expect(mockInitializeApp).not.toHaveBeenCalled(); + }); + + it( + String.raw`replaces literal \\n escape sequences in privateKey with real newlines`, + () => { + const fastify = makeFastify(); + const config = makeConfig(); + + initializeFirebase(config, fastify); + + expect(mockCert).toHaveBeenCalledWith( + expect.objectContaining({ + privateKey: expect.stringContaining("\n"), + }), + ); + const passedKey: string = mockCert.mock.calls[0][0].privateKey; + expect(passedKey).not.toContain(String.raw`\n`); + }, + ); + + it("passes projectId and clientEmail unchanged to admin.credential.cert", () => { + const fastify = makeFastify(); + const config = makeConfig(); + + initializeFirebase(config, fastify); + + expect(mockCert).toHaveBeenCalledWith( + expect.objectContaining({ + clientEmail: baseCredentials.clientEmail, + projectId: baseCredentials.projectId, + }), + ); + }); + + it("logs error and continues when admin.initializeApp throws", () => { + const error = new Error("initializeApp failed"); + mockInitializeApp.mockImplementationOnce(() => { + throw error; + }); + const fastify = makeFastify(); + const config = makeConfig(); + + expect(() => initializeFirebase(config, fastify)).not.toThrow(); + expect(fastify.log.error).toHaveBeenCalledWith( + "Failed to initialize firebase", + ); + expect(fastify.log.error).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/firebase/src/__test__/isFirebaseEnabled.test.ts b/packages/firebase/src/__test__/isFirebaseEnabled.test.ts new file mode 100644 index 000000000..8ff131416 --- /dev/null +++ b/packages/firebase/src/__test__/isFirebaseEnabled.test.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import { describe, expect, it, vi } from "vitest"; + +import isFirebaseEnabled from "../middlewares/isFirebaseEnabled"; + +const makeFastify = (enabled?: boolean) => + ({ + config: { + firebase: { enabled }, + }, + httpErrors: { + notFound: vi.fn().mockReturnValue(new Error("Firebase is disabled")), + }, + }) as unknown as FastifyInstance; + +describe("isFirebaseEnabled", () => { + it("throws notFound when config.firebase.enabled === false", async () => { + const fastify = makeFastify(false); + const hook = isFirebaseEnabled(fastify); + + await expect(hook()).rejects.toThrow("Firebase is disabled"); + expect(fastify.httpErrors.notFound).toHaveBeenCalledWith( + "Firebase is disabled", + ); + }); + + it("resolves without throwing when config.firebase.enabled is undefined", async () => { + const fastify = makeFastify(); + const hook = isFirebaseEnabled(fastify); + + await expect(hook()).resolves.toBeUndefined(); + }); + + it("resolves without throwing when config.firebase.enabled === true", async () => { + const fastify = makeFastify(true); + const hook = isFirebaseEnabled(fastify); + + await expect(hook()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/firebase/src/__test__/libraryAndMigrations.test.ts b/packages/firebase/src/__test__/libraryAndMigrations.test.ts new file mode 100644 index 000000000..97d5aa306 --- /dev/null +++ b/packages/firebase/src/__test__/libraryAndMigrations.test.ts @@ -0,0 +1,79 @@ +/* istanbul ignore file */ +import { describe, expect, it, vi } from "vitest"; + +const { + createUserDevicesTableQueryMock, + messagingMock, + mockQueryToken, + sendEachForMulticastMock, +} = vi.hoisted(() => ({ + createUserDevicesTableQueryMock: vi.fn(), + messagingMock: vi.fn(), + mockQueryToken: { sql: "SELECT 1", type: "SLONIK_TOKEN" }, + sendEachForMulticastMock: vi.fn(), +})); + +vi.mock("../migrations/queries", () => ({ + createUserDevicesTableQuery: createUserDevicesTableQueryMock, +})); + +vi.mock("firebase-admin", () => ({ + default: { + messaging: messagingMock, + }, +})); + +describe("runMigrations", async () => { + const { default: runMigrations } = + await import("../migrations/runMigrations"); + + it("executes createUserDevicesTableQuery inside a database connection", async () => { + createUserDevicesTableQueryMock.mockReturnValue(mockQueryToken); + + const query = vi.fn().mockResolvedValue(); + const connect = vi.fn().mockImplementation(async (handler) => { + await handler({ query }); + }); + + const database = { + connect, + }; + const config = { + firebase: { enabled: true }, + }; + + await runMigrations( + database as Parameters[0], + config as Parameters[1], + ); + + expect(createUserDevicesTableQueryMock).toHaveBeenCalledWith(config); + expect(connect).toHaveBeenCalledOnce(); + expect(query).toHaveBeenCalledWith(mockQueryToken); + }); +}); + +describe("sendPushNotification", async () => { + const { default: sendPushNotification } = + await import("../lib/sendPushNotification"); + + it("forwards multicast messages to firebase-admin messaging", async () => { + sendEachForMulticastMock.mockResolvedValue(); + messagingMock.mockReturnValue({ + sendEachForMulticast: sendEachForMulticastMock, + }); + + const message = { + notification: { + body: "Body", + title: "Title", + }, + tokens: ["token-a", "token-b"], + }; + + await sendPushNotification(message); + + expect(messagingMock).toHaveBeenCalledOnce(); + expect(sendEachForMulticastMock).toHaveBeenCalledWith(message); + }); +}); diff --git a/packages/firebase/src/__test__/notificationResolver.test.ts b/packages/firebase/src/__test__/notificationResolver.test.ts new file mode 100644 index 000000000..1d97c35d0 --- /dev/null +++ b/packages/firebase/src/__test__/notificationResolver.test.ts @@ -0,0 +1,121 @@ +import type { MercuriusContext } from "mercurius"; + +/* istanbul ignore file */ +import { mercurius } from "mercurius"; +import { describe, expect, it, vi } from "vitest"; + +import notificationResolver from "../model/notification/graphql/resolver"; + +// vi.hoisted ensures these are available inside the vi.mock factory (which is hoisted) +const { sendPushNotificationMock } = vi.hoisted(() => ({ + sendPushNotificationMock: vi.fn().mockImplementation(async () => {}), +})); + +vi.mock("../lib/sendPushNotification", () => ({ + default: sendPushNotificationMock, +})); + +vi.mock("../model/userDevice/service", () => ({ + default: vi.fn().mockImplementation(() => ({ + getByUserId: vi + .fn() + .mockResolvedValue([{ deviceToken: "token-abc", userId: "user-1" }]), + })), +})); + +const makeContext = ( + overrides: Partial = {}, +): MercuriusContext => + ({ + app: { log: { error: vi.fn() } }, + config: { firebase: { enabled: true } }, + database: {}, + dbSchema: "", + user: { id: "sender-1" }, + ...overrides, + }) as unknown as MercuriusContext; + +const arguments_ = { + data: { + body: "World", + data: {}, + title: "Hello", + userId: "user-1", + }, +}; + +describe("notificationResolver.sendNotification", () => { + it("returns 401 ErrorWithProps when user is not in context", async () => { + const context = makeContext({ user: undefined }); + const result = await notificationResolver.Mutation.sendNotification( + undefined, + arguments_, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(401); + }); + + it("returns 404 ErrorWithProps when firebase is disabled", async () => { + const context = makeContext({ + config: { + firebase: { enabled: false }, + } as unknown as MercuriusContext["config"], + }); + const result = await notificationResolver.Mutation.sendNotification( + undefined, + arguments_, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(404); + }); + + it("returns 400 ErrorWithProps when userId is missing in args", async () => { + const context = makeContext(); + const argumentsWithoutUserId = { data: { ...arguments_.data, userId: "" } }; + const result = await notificationResolver.Mutation.sendNotification( + undefined, + argumentsWithoutUserId, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(400); + }); + + it("returns 404 ErrorWithProps when receiver has no registered devices", async () => { + const { default: UserDeviceService } = + await import("../model/userDevice/service"); + vi.mocked(UserDeviceService).mockImplementationOnce( + () => + ({ + getByUserId: vi.fn().mockResolvedValue([]), + }) as unknown as ReturnType, + ); + + const context = makeContext(); + const result = await notificationResolver.Mutation.sendNotification( + undefined, + arguments_, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(404); + }); + + it("calls sendPushNotification and returns success message when all conditions met", async () => { + const context = makeContext(); + const result = await notificationResolver.Mutation.sendNotification( + undefined, + arguments_, + context, + ); + + expect(sendPushNotificationMock).toHaveBeenCalled(); + expect(result).toEqual({ message: "Notification sent successfully" }); + }); +}); diff --git a/packages/firebase/src/__test__/plugin.test.ts b/packages/firebase/src/__test__/plugin.test.ts new file mode 100644 index 000000000..88ac0623e --- /dev/null +++ b/packages/firebase/src/__test__/plugin.test.ts @@ -0,0 +1,296 @@ +import type { FastifyInstance } from "fastify"; + +/* istanbul ignore file */ +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ROUTE_SEND_NOTIFICATION, + ROUTE_USER_DEVICE_ADD, + ROUTE_USER_DEVICE_REMOVE, +} from "../constants"; + +// The route schemas reference "ErrorResponse#" which is registered by the error-handler plugin. +// We add it directly here so the test instance can resolve the $ref. +const errorResponseSchema = { + $id: "ErrorResponse", + additionalProperties: true, + properties: { + code: { type: "string" }, + error: { type: "string" }, + message: { type: "string" }, + statusCode: { type: "number" }, + }, + type: "object", +}; + +const runMigrationsMock = vi.fn().mockResolvedValue(); +const initializeFirebaseMock = vi.fn(); + +vi.mock("../migrations/runMigrations", () => ({ + default: runMigrationsMock, +})); + +vi.mock("../lib/initializeFirebase", () => ({ + default: initializeFirebaseMock, +})); + +const mockSlonik = { connect: vi.fn(), pool: {}, query: vi.fn() }; +const mockVerifySession = async () => {}; + +/** + * Builds a Fastify instance decorated with all the dependencies the firebase + * plugin reads from the fastify instance (config, slonik, verifySession, httpErrors). + */ +const buildFastify = (firebaseConfig: Record = {}) => { + const fastify = Fastify({ logger: false }); + + fastify.addSchema(errorResponseSchema); + fastify.decorate("config", { + firebase: { + enabled: true, + routePrefix: "/api", + ...firebaseConfig, + }, + }); + fastify.decorate("slonik", mockSlonik); + // verifySession is called at route registration time to produce a preHandler + fastify.decorate("verifySession", () => mockVerifySession); + fastify.decorate("httpErrors", { + notFound: (message: string) => + Object.assign(new Error(message), { statusCode: 404 }), + unauthorized: (message: string) => + Object.assign(new Error(message), { statusCode: 401 }), + }); + + return fastify; +}; + +describe("firebasePlugin — initialization", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not call runMigrations when enabled === false", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).not.toHaveBeenCalled(); + await fastify.close(); + }); + + it("does not call initializeFirebase when enabled === false", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect(initializeFirebaseMock).not.toHaveBeenCalled(); + await fastify.close(); + }); + + it("calls runMigrations when enabled is not false", async () => { + fastify = buildFastify({ enabled: true }); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).toHaveBeenCalledOnce(); + await fastify.close(); + }); + + it("calls initializeFirebase when enabled is not false", async () => { + fastify = buildFastify({ enabled: true }); + await fastify.register(plugin); + await fastify.ready(); + + expect(initializeFirebaseMock).toHaveBeenCalledOnce(); + await fastify.close(); + }); + + it("passes slonik and config to runMigrations", async () => { + fastify = buildFastify({ enabled: true }); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).toHaveBeenCalledWith( + mockSlonik, + expect.objectContaining({ firebase: expect.any(Object) }), + ); + await fastify.close(); + }); +}); + +describe("firebasePlugin — userDevice route registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("registers POST /user-device route by default", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_USER_DEVICE_ADD}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("registers DELETE /user-device route by default", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "DELETE", + url: `${fastify.config.firebase.routePrefix}${ROUTE_USER_DEVICE_REMOVE}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("skips userDevice routes when routes.userDevices.disabled === true", async () => { + fastify = buildFastify({ + enabled: false, + routes: { userDevices: { disabled: true } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_USER_DEVICE_ADD}`, + }), + ).toBe(false); + await fastify.close(); + }); + + it("registers user device routes under a custom routePrefix", async () => { + const customPrefix = "/v2/firebase"; + fastify = buildFastify({ enabled: false, routePrefix: customPrefix }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${customPrefix}${ROUTE_USER_DEVICE_ADD}`, + }), + ).toBe(true); + + expect( + fastify.hasRoute({ + method: "DELETE", + url: `${customPrefix}${ROUTE_USER_DEVICE_REMOVE}`, + }), + ).toBe(true); + await fastify.close(); + }); +}); + +describe("firebasePlugin — notification route registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not register notification route when notification.test.enabled is not set", async () => { + fastify = buildFastify({ enabled: false }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_SEND_NOTIFICATION}`, + }), + ).toBe(false); + await fastify.close(); + }); + + it("registers notification route when notification.test.enabled === true", async () => { + fastify = buildFastify({ + enabled: false, + notification: { test: { enabled: true, path: ROUTE_SEND_NOTIFICATION } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_SEND_NOTIFICATION}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("registers notification test route at default path when test is enabled but path is omitted", async () => { + fastify = buildFastify({ + enabled: false, + notification: { test: { enabled: true } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_SEND_NOTIFICATION}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("uses custom notification test path when configured", async () => { + const customPath = "/custom-notify"; + fastify = buildFastify({ + enabled: false, + notification: { test: { enabled: true, path: customPath } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${customPath}`, + }), + ).toBe(true); + await fastify.close(); + }); + + it("skips notification routes when routes.notifications.disabled === true", async () => { + fastify = buildFastify({ + enabled: false, + notification: { test: { enabled: true, path: ROUTE_SEND_NOTIFICATION } }, + routes: { notifications: { disabled: true } }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ + method: "POST", + url: `${fastify.config.firebase.routePrefix}${ROUTE_SEND_NOTIFICATION}`, + }), + ).toBe(false); + await fastify.close(); + }); +}); diff --git a/packages/firebase/src/__test__/queries.test.ts b/packages/firebase/src/__test__/queries.test.ts new file mode 100644 index 000000000..273762a11 --- /dev/null +++ b/packages/firebase/src/__test__/queries.test.ts @@ -0,0 +1,41 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +/* istanbul ignore file */ +import { describe, expect, it } from "vitest"; + +import { TABLE_USER_DEVICES } from "../constants"; +import { createUserDevicesTableQuery } from "../migrations/queries"; + +const makeConfig = (tableName?: string): ApiConfig => + ({ + firebase: { + table: tableName ? { userDevices: { name: tableName } } : undefined, + }, + }) as unknown as ApiConfig; + +describe("createUserDevicesTableQuery", () => { + it("uses TABLE_USER_DEVICES constant as default table name when not configured", () => { + const query = createUserDevicesTableQuery(makeConfig()); + + expect(query.sql).toContain(TABLE_USER_DEVICES); + }); + + it("uses custom table name from config.firebase.table.userDevices.name", () => { + const query = createUserDevicesTableQuery(makeConfig("custom_devices")); + + expect(query.sql).toContain("custom_devices"); + expect(query.sql).not.toContain(TABLE_USER_DEVICES); + }); + + it("generates a CREATE TABLE IF NOT EXISTS statement", () => { + const query = createUserDevicesTableQuery(makeConfig()); + + expect(query.sql).toMatch(/CREATE TABLE IF NOT EXISTS/i); + }); + + it("creates index on user_id and device_token", () => { + const query = createUserDevicesTableQuery(makeConfig()); + + expect(query.sql).toMatch(/CREATE INDEX IF NOT EXISTS/i); + }); +}); diff --git a/packages/firebase/src/__test__/service.test.ts b/packages/firebase/src/__test__/service.test.ts new file mode 100644 index 000000000..686de39ca --- /dev/null +++ b/packages/firebase/src/__test__/service.test.ts @@ -0,0 +1,150 @@ +/* istanbul ignore file */ +import { newDb } from "pg-mem"; +import { describe, expect, it } from "vitest"; + +import UserDeviceService from "../model/userDevice/service"; +import createConfig from "./helpers/createConfig"; +import createDatabase from "./helpers/createDatabase"; + +const CREATE_TABLE_SQL = ` + CREATE TABLE user_devices ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + device_token VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); +`; + +describe("UserDeviceService — getByUserId", async () => { + const db = newDb(); + db.public.none(CREATE_TABLE_SQL); + db.public.none(` + INSERT INTO user_devices (user_id, device_token) VALUES + ('user-1', 'token-a'), + ('user-1', 'token-b'), + ('user-2', 'token-c'); + `); + + const config = createConfig(); + const database = await createDatabase({ db }); + const service = new UserDeviceService(config, database); + + it("returns all devices for a given userId", async () => { + const result = await service.getByUserId("user-1"); + + expect(result).toHaveLength(2); + expect(result?.every((d) => d.userId === "user-1")).toBe(true); + }); + + it("returns device tokens for the user", async () => { + const result = await service.getByUserId("user-1"); + const tokens = result?.map((d) => d.deviceToken).toSorted(); + + expect(tokens).toEqual(["token-a", "token-b"]); + }); + + it("returns an empty array when user has no devices", async () => { + const result = await service.getByUserId("user-unknown"); + + expect(result).toEqual([]); + }); + + it("does not return devices belonging to other users", async () => { + const result = await service.getByUserId("user-2"); + + expect(result).toHaveLength(1); + expect(result?.[0].deviceToken).toBe("token-c"); + }); +}); + +describe("UserDeviceService — removeByDeviceToken", async () => { + const db = newDb(); + db.public.none(CREATE_TABLE_SQL); + db.public.none(` + INSERT INTO user_devices (user_id, device_token) VALUES + ('user-1', 'token-to-delete'), + ('user-1', 'token-keep'); + `); + + const config = createConfig(); + const database = await createDatabase({ db }); + const service = new UserDeviceService(config, database); + + it("returns the deleted device record", async () => { + const result = await service.removeByDeviceToken("token-to-delete"); + + expect(result).toBeDefined(); + expect(result?.deviceToken).toBe("token-to-delete"); + expect(result?.userId).toBe("user-1"); + }); + + it("removes the device from the database", async () => { + const remaining = await service.getByUserId("user-1"); + + // token-to-delete was removed in the previous test, only token-keep remains + expect(remaining).toHaveLength(1); + expect(remaining?.[0].deviceToken).toBe("token-keep"); + }); + + it("returns null when the token does not exist", async () => { + const result = await service.removeByDeviceToken("nonexistent-token"); + + // slonik's maybeOne returns null (not undefined) when no row is found + expect(result).toBeNull(); + }); +}); + +describe("UserDeviceService — preCreate (deduplication)", async () => { + const db = newDb(); + db.public.none(CREATE_TABLE_SQL); + db.public.none(` + INSERT INTO user_devices (user_id, device_token) VALUES ('user-1', 'existing-token'); + `); + + const config = createConfig(); + const database = await createDatabase({ db }); + const service = new UserDeviceService(config, database); + + it("removes the existing row with the same deviceToken before creating", async () => { + await service.create({ deviceToken: "existing-token", userId: "user-2" }); + + const user1Devices = await service.getByUserId("user-1"); + expect(user1Devices).toHaveLength(0); + }); + + it("creates a new row with the new userId after deduplication", async () => { + const user2Devices = await service.getByUserId("user-2"); + + expect(user2Devices).toHaveLength(1); + expect(user2Devices?.[0].deviceToken).toBe("existing-token"); + expect(user2Devices?.[0].userId).toBe("user-2"); + }); +}); + +describe("UserDeviceService — custom table name", async () => { + const db = newDb(); + db.public.none(` + CREATE TABLE custom_devices ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + device_token VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO custom_devices (user_id, device_token) VALUES ('user-1', 'token-a'); + `); + + const config = createConfig({ + table: { userDevices: { name: "custom_devices" } }, + }); + const database = await createDatabase({ db }); + const service = new UserDeviceService(config, database); + + it("queries the custom table name configured in config.firebase.table.userDevices.name", async () => { + const result = await service.getByUserId("user-1"); + + expect(result).toHaveLength(1); + expect(result?.[0].deviceToken).toBe("token-a"); + }); +}); diff --git a/packages/firebase/src/__test__/sqlFactory.test.ts b/packages/firebase/src/__test__/sqlFactory.test.ts new file mode 100644 index 000000000..3f6aa6be7 --- /dev/null +++ b/packages/firebase/src/__test__/sqlFactory.test.ts @@ -0,0 +1,70 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +/* istanbul ignore file */ +import { describe, expect, it, vi } from "vitest"; + +import { TABLE_USER_DEVICES } from "../constants"; +import UserDeviceSqlFactory from "../model/userDevice/sqlFactory"; + +const makeConfig = (tableName?: string): ApiConfig => + ({ + firebase: { + table: tableName ? { userDevices: { name: tableName } } : undefined, + }, + }) as unknown as ApiConfig; + +// We only need a minimal database stub — SQL tokens are built without executing queries +const mockDatabase = { + connect: vi.fn(), + pool: {}, + query: vi.fn(), +} as unknown as Parameters[1]; + +describe("UserDeviceSqlFactory — table getter", () => { + it("returns TABLE_USER_DEVICES when not configured in config", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + + expect(factory.table).toBe(TABLE_USER_DEVICES); + }); + + it("returns the custom table name from config.firebase.table.userDevices.name", () => { + const factory = new UserDeviceSqlFactory( + makeConfig("my_devices"), + mockDatabase, + ); + + expect(factory.table).toBe("my_devices"); + }); +}); + +describe("UserDeviceSqlFactory — getDeleteExistingTokenSql", () => { + it("generates a DELETE SQL statement", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + const query = factory.getDeleteExistingTokenSql("token-abc"); + + expect(query.sql).toMatch(/DELETE/i); + }); + + it("includes RETURNING * in the DELETE statement", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + const query = factory.getDeleteExistingTokenSql("token-abc"); + + expect(query.sql).toMatch(/RETURNING \*/i); + }); +}); + +describe("UserDeviceSqlFactory — getFindByUserIdSql", () => { + it("generates a SELECT SQL statement", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + const query = factory.getFindByUserIdSql("user-123"); + + expect(query.sql).toMatch(/SELECT/i); + }); + + it("filters by user_id", () => { + const factory = new UserDeviceSqlFactory(makeConfig(), mockDatabase); + const query = factory.getFindByUserIdSql("user-123"); + + expect(query.sql).toMatch(/user_id/i); + }); +}); diff --git a/packages/firebase/src/__test__/userDeviceResolver.test.ts b/packages/firebase/src/__test__/userDeviceResolver.test.ts new file mode 100644 index 000000000..3114a435c --- /dev/null +++ b/packages/firebase/src/__test__/userDeviceResolver.test.ts @@ -0,0 +1,160 @@ +import type { MercuriusContext } from "mercurius"; + +/* istanbul ignore file */ +import { mercurius } from "mercurius"; +import { describe, expect, it, vi } from "vitest"; + +import userDeviceResolver from "../model/userDevice/graphql/resolver"; + +const mockCreate = vi + .fn() + .mockResolvedValue({ deviceToken: "token-abc", id: 1, userId: "user-1" }); +const mockGetByUserId = vi + .fn() + .mockResolvedValue([{ deviceToken: "token-abc", id: 1, userId: "user-1" }]); +const mockRemoveByDeviceToken = vi + .fn() + .mockResolvedValue({ deviceToken: "token-abc", id: 1, userId: "user-1" }); + +vi.mock("../model/userDevice/service", () => ({ + default: vi.fn().mockImplementation(() => ({ + create: mockCreate, + getByUserId: mockGetByUserId, + removeByDeviceToken: mockRemoveByDeviceToken, + })), +})); + +const makeContext = ( + overrides: Partial = {}, +): MercuriusContext => + ({ + app: { log: { error: vi.fn() } }, + config: { firebase: { enabled: true } }, + database: {}, + dbSchema: "", + user: { id: "user-1" }, + ...overrides, + }) as unknown as MercuriusContext; + +describe("userDeviceResolver.addUserDevice", () => { + it("returns 404 ErrorWithProps when firebase is disabled", async () => { + const context = makeContext({ + config: { + firebase: { enabled: false }, + } as unknown as MercuriusContext["config"], + }); + const result = await userDeviceResolver.Mutation.addUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(404); + }); + + it("returns 401 ErrorWithProps when user is not in context", async () => { + const context = makeContext({ user: undefined }); + const result = await userDeviceResolver.Mutation.addUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(401); + }); + + it("calls service.create with userId and deviceToken and returns result", async () => { + const context = makeContext(); + const result = await userDeviceResolver.Mutation.addUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(mockCreate).toHaveBeenCalledWith({ + deviceToken: "token-abc", + userId: "user-1", + }); + expect(result).toEqual({ + deviceToken: "token-abc", + id: 1, + userId: "user-1", + }); + }); +}); + +describe("userDeviceResolver.removeUserDevice", () => { + it("returns 404 ErrorWithProps when firebase is disabled", async () => { + const context = makeContext({ + config: { + firebase: { enabled: false }, + } as unknown as MercuriusContext["config"], + }); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(404); + }); + + it("returns 401 ErrorWithProps when user is not in context", async () => { + const context = makeContext({ user: undefined }); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(401); + }); + + it("returns 403 ErrorWithProps when user has no registered devices", async () => { + mockGetByUserId.mockResolvedValueOnce([]); + const context = makeContext(); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(403); + }); + + it("returns 403 ErrorWithProps when device is not owned by requesting user", async () => { + mockGetByUserId.mockResolvedValueOnce([ + { deviceToken: "different-token", id: 2, userId: "user-1" }, + ]); + const context = makeContext(); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(result).toBeInstanceOf(mercurius.ErrorWithProps); + expect((result as mercurius.ErrorWithProps).statusCode).toBe(403); + }); + + it("calls service.removeByDeviceToken and returns result when device is owned by user", async () => { + const context = makeContext(); + const result = await userDeviceResolver.Mutation.removeUserDevice( + undefined, + { data: { deviceToken: "token-abc" } }, + context, + ); + + expect(mockRemoveByDeviceToken).toHaveBeenCalledWith("token-abc"); + expect(result).toEqual({ + deviceToken: "token-abc", + id: 1, + userId: "user-1", + }); + }); +}); diff --git a/packages/firebase/src/index.ts b/packages/firebase/src/index.ts index 65bb99685..9939b7e86 100644 --- a/packages/firebase/src/index.ts +++ b/packages/firebase/src/index.ts @@ -1,10 +1,10 @@ import { verifySession } from "supertokens-node/recipe/session/framework/fastify"; +import type { User } from "./types"; + import notificationHandlers from "./model/notification/handlers"; import deviceHandlers from "./model/userDevice/handlers"; -import type { User } from "./types"; - declare module "fastify" { interface FastifyInstance { verifySession: typeof verifySession; @@ -24,12 +24,28 @@ declare module "mercurius" { declare module "@prefabs.tech/fastify-config" { interface ApiConfig { firebase: { - enabled?: boolean; credentials?: { - projectId: string; - privateKey: string; clientEmail: string; + privateKey: string; + projectId: string; + }; + enabled?: boolean; + handlers?: { + notification?: { + sendNotification?: typeof notificationHandlers.sendNotification; + }; + userDevice?: { + addUserDevice?: typeof deviceHandlers.addUserDevice; + removeUserDevice?: typeof deviceHandlers.removeUserDevice; + }; + }; + notification?: { + test?: { + enabled: boolean; + path: string; + }; }; + routePrefix?: string; routes?: { notifications?: { disabled: boolean; @@ -38,41 +54,25 @@ declare module "@prefabs.tech/fastify-config" { disabled: boolean; }; }; - routePrefix?: string; table?: { userDevices?: { name: string; }; }; - notification?: { - test?: { - enabled: boolean; - path: string; - }; - }; - handlers?: { - userDevice?: { - addUserDevice?: typeof deviceHandlers.addUserDevice; - removeUserDevice?: typeof deviceHandlers.removeUserDevice; - }; - notification?: { - sendNotification?: typeof notificationHandlers.sendNotification; - }; - }; }; } } -export { default } from "./plugin"; +export * from "./constants"; +export { default as firebaseSchema } from "./graphql/schema"; +export * from "./lib"; + +export * from "./migrations/queries"; export { default as notificationRoutes } from "./model/notification/controller"; export { default as notificationResolver } from "./model/notification/graphql/resolver"; +export { default as userDeviceRoutes } from "./model/userDevice/controller"; export { default as userDeviceResolver } from "./model/userDevice/graphql/resolver"; -export { default as userDeviceRoutes } from "./model/userDevice/controller"; export { default as UserDeviceService } from "./model/userDevice/service"; -export { default as firebaseSchema } from "./graphql/schema"; - -export * from "./constants"; -export * from "./lib"; -export * from "./migrations/queries"; +export { default } from "./plugin"; diff --git a/packages/firebase/src/lib/initializeFirebase.ts b/packages/firebase/src/lib/initializeFirebase.ts index 9844233fe..da9d8654e 100644 --- a/packages/firebase/src/lib/initializeFirebase.ts +++ b/packages/firebase/src/lib/initializeFirebase.ts @@ -1,8 +1,8 @@ -import admin from "firebase-admin"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { FastifyInstance } from "fastify"; +import admin from "firebase-admin"; + const initializeFirebase = (config: ApiConfig, fastify: FastifyInstance) => { if (admin.apps.length > 0) { return; @@ -16,12 +16,12 @@ const initializeFirebase = (config: ApiConfig, fastify: FastifyInstance) => { try { admin.initializeApp({ credential: admin.credential.cert({ - projectId: config.firebase.credentials?.projectId, + clientEmail: config.firebase.credentials?.clientEmail, privateKey: config.firebase.credentials?.privateKey.replaceAll( String.raw`\n`, "\n", ), - clientEmail: config.firebase.credentials?.clientEmail, + projectId: config.firebase.credentials?.projectId, }), }); } catch (error) { diff --git a/packages/firebase/src/lib/sendPushNotification.ts b/packages/firebase/src/lib/sendPushNotification.ts index 2b8a7430b..69f5234dc 100644 --- a/packages/firebase/src/lib/sendPushNotification.ts +++ b/packages/firebase/src/lib/sendPushNotification.ts @@ -1,7 +1,7 @@ -import admin from "firebase-admin"; - import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; +import admin from "firebase-admin"; + const sendPushNotification = async (message: MulticastMessage) => { await admin.messaging().sendEachForMulticast(message); }; diff --git a/packages/firebase/src/migrations/queries.ts b/packages/firebase/src/migrations/queries.ts index 20d7a7d78..735711634 100644 --- a/packages/firebase/src/migrations/queries.ts +++ b/packages/firebase/src/migrations/queries.ts @@ -1,11 +1,11 @@ -import { sql } from "slonik"; - -import { TABLE_USER_DEVICES } from "../constants"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { QuerySqlToken } from "slonik"; import type { ZodTypeAny } from "zod"; +import { sql } from "slonik"; + +import { TABLE_USER_DEVICES } from "../constants"; + const createUserDevicesTableQuery = ( config: ApiConfig, ): QuerySqlToken => { diff --git a/packages/firebase/src/migrations/runMigrations.ts b/packages/firebase/src/migrations/runMigrations.ts index e89c1ffae..ac681adec 100644 --- a/packages/firebase/src/migrations/runMigrations.ts +++ b/packages/firebase/src/migrations/runMigrations.ts @@ -1,8 +1,8 @@ -import { createUserDevicesTableQuery } from "./queries"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import { createUserDevicesTableQuery } from "./queries"; + const runMigrations = async (database: Database, config: ApiConfig) => { await database.connect(async (connection) => { await connection.query(createUserDevicesTableQuery(config)); diff --git a/packages/firebase/src/model/notification/controller.ts b/packages/firebase/src/model/notification/controller.ts index ae6f28863..c00c89453 100644 --- a/packages/firebase/src/model/notification/controller.ts +++ b/packages/firebase/src/model/notification/controller.ts @@ -1,12 +1,16 @@ -import handlers from "./handlers"; -import { sendNotificationSchema } from "./schema"; +import type { FastifyInstance } from "fastify"; + import { ROUTE_SEND_NOTIFICATION } from "../../constants"; import isFirebaseEnabled from "../../middlewares/isFirebaseEnabled"; +import handlers from "./handlers"; +import { sendNotificationSchema } from "./schema"; -import type { FastifyInstance } from "fastify"; - +/** + * Registers an authenticated test endpoint to send a push notification to a user’s + * registered device tokens (enabled via `config.firebase.notification.test`). + */ const plugin = async (fastify: FastifyInstance) => { - const handlersConfig = fastify.config.firebase.handlers?.userDevice; + const handlersConfig = fastify.config.firebase.handlers?.notification; const notificationConfig = fastify.config.firebase.notification; if (notificationConfig?.test?.enabled) { @@ -16,7 +20,7 @@ const plugin = async (fastify: FastifyInstance) => { preHandler: [fastify.verifySession(), isFirebaseEnabled(fastify)], schema: sendNotificationSchema, }, - handlersConfig?.addUserDevice || handlers.sendNotification, + handlersConfig?.sendNotification || handlers.sendNotification, ); } }; diff --git a/packages/firebase/src/model/notification/graphql/resolver.ts b/packages/firebase/src/model/notification/graphql/resolver.ts index e493625a2..a54955eae 100644 --- a/packages/firebase/src/model/notification/graphql/resolver.ts +++ b/packages/firebase/src/model/notification/graphql/resolver.ts @@ -1,27 +1,27 @@ +import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; +import type { MercuriusContext } from "mercurius"; + import { mercurius } from "mercurius"; import { sendPushNotification } from "../../../lib"; import UserDeviceService from "../../userDevice/service"; -import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; -import type { MercuriusContext } from "mercurius"; - const Mutation = { sendNotification: async ( parent: unknown, arguments_: { data: { - userId: string; - title: string; body: string; data: { [key: string]: string; }; + title: string; + userId: string; }; }, context: MercuriusContext, ) => { - const { app, config, dbSchema, database, user } = context; + const { app, config, database, dbSchema, user } = context; if (!user) { return new mercurius.ErrorWithProps("unauthorized", {}, 401); @@ -32,7 +32,7 @@ const Mutation = { } try { - const { userId: receiverId, title, body, data } = arguments_.data; + const { body, data, title, userId: receiverId } = arguments_.data; if (!receiverId) { return new mercurius.ErrorWithProps("Receiver id is required", {}, 400); @@ -59,12 +59,12 @@ const Mutation = { ); const message: MulticastMessage = { - tokens, + data, notification: { - title, body, + title, }, - data, + tokens, }; await sendPushNotification(message); diff --git a/packages/firebase/src/model/notification/handlers/sendNotification.ts b/packages/firebase/src/model/notification/handlers/sendNotification.ts index 2c6ee657a..4a43a4ce8 100644 --- a/packages/firebase/src/model/notification/handlers/sendNotification.ts +++ b/packages/firebase/src/model/notification/handlers/sendNotification.ts @@ -1,11 +1,12 @@ -import { sendPushNotification } from "../../../lib"; -import DeviceService from "../../userDevice/service"; - -import type { TestNotificationInput } from "../../../types"; import type { FastifyReply } from "fastify"; import type { MulticastMessage } from "firebase-admin/lib/messaging/messaging-api"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { TestNotificationInput } from "../../../types"; + +import { sendPushNotification } from "../../../lib"; +import DeviceService from "../../userDevice/service"; + const testPushNotification = async ( request: SessionRequest, reply: FastifyReply, @@ -18,8 +19,8 @@ const testPushNotification = async ( const { body, - title, data, + title, userId: receiverId, } = request.body as TestNotificationInput; @@ -41,10 +42,10 @@ const testPushNotification = async ( const message: MulticastMessage = { android: { - priority: "high", notification: { sound: "default", }, + priority: "high", }, apns: { payload: { @@ -53,16 +54,16 @@ const testPushNotification = async ( }, }, }, - tokens, - notification: { - title, - body, - }, data: { ...data, + body, title, + }, + notification: { body, + title, }, + tokens, }; await sendPushNotification(message); diff --git a/packages/firebase/src/model/notification/schema.ts b/packages/firebase/src/model/notification/schema.ts index cd80537d9..e94b41348 100644 --- a/packages/firebase/src/model/notification/schema.ts +++ b/packages/firebase/src/model/notification/schema.ts @@ -1,26 +1,26 @@ export const sendNotificationSchema = { - description: "Send a notification to a specific user", - operationId: "sendNotification", body: { - type: "object", properties: { - title: { type: "string" }, message: { type: "string" }, + title: { type: "string" }, userId: { type: "string" }, }, required: ["title", "message", "userId"], + type: "object", }, + description: "Send a notification to a specific user", + operationId: "sendNotification", response: { 200: { - type: "object", properties: { - success: { type: "boolean" }, message: { type: "string" }, + success: { type: "boolean" }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/firebase/src/model/userDevice/controller.ts b/packages/firebase/src/model/userDevice/controller.ts index 4587a71ee..f9285679f 100644 --- a/packages/firebase/src/model/userDevice/controller.ts +++ b/packages/firebase/src/model/userDevice/controller.ts @@ -1,12 +1,12 @@ -import handlers from "./handlers"; -import { deleteUserDeviceSchema, postUserDeviceSchema } from "./schema"; +import type { FastifyInstance } from "fastify"; + import { ROUTE_USER_DEVICE_ADD, ROUTE_USER_DEVICE_REMOVE, } from "../../constants"; import isFirebaseEnabled from "../../middlewares/isFirebaseEnabled"; - -import type { FastifyInstance } from "fastify"; +import handlers from "./handlers"; +import { deleteUserDeviceSchema, postUserDeviceSchema } from "./schema"; const plugin = async (fastify: FastifyInstance) => { const handlersConfig = fastify.config.firebase.handlers?.userDevice; diff --git a/packages/firebase/src/model/userDevice/graphql/resolver.ts b/packages/firebase/src/model/userDevice/graphql/resolver.ts index ca39c005c..1f350b59b 100644 --- a/packages/firebase/src/model/userDevice/graphql/resolver.ts +++ b/packages/firebase/src/model/userDevice/graphql/resolver.ts @@ -12,7 +12,7 @@ const Mutation = { }, context: MercuriusContext, ) => { - const { app, config, dbSchema, database, user } = context; + const { app, config, database, dbSchema, user } = context; if (config.firebase.enabled === false) { return new mercurius.ErrorWithProps("Firebase is not enabled", {}, 404); @@ -27,7 +27,7 @@ const Mutation = { const service = new Service(config, database, dbSchema); - return await service.create({ userId: user.id, deviceToken }); + return await service.create({ deviceToken, userId: user.id }); } catch (error) { app.log.error(error); @@ -47,7 +47,7 @@ const Mutation = { }, context: MercuriusContext, ) => { - const { app, config, dbSchema, database, user } = context; + const { app, config, database, dbSchema, user } = context; if (config.firebase.enabled === false) { return new mercurius.ErrorWithProps("Firebase is not enabled", {}, 404); diff --git a/packages/firebase/src/model/userDevice/handlers/addUserDevice.ts b/packages/firebase/src/model/userDevice/handlers/addUserDevice.ts index 6f09182ac..8f29555f2 100644 --- a/packages/firebase/src/model/userDevice/handlers/addUserDevice.ts +++ b/packages/firebase/src/model/userDevice/handlers/addUserDevice.ts @@ -1,9 +1,10 @@ -import Service from "../service"; - -import type { UserDeviceCreateInput } from "../../../types"; import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { UserDeviceCreateInput } from "../../../types"; + +import Service from "../service"; + const addUserDevice = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, slonik, user } = request; @@ -15,7 +16,7 @@ const addUserDevice = async (request: SessionRequest, reply: FastifyReply) => { const service = new Service(config, slonik, dbSchema); - reply.send(await service.create({ userId: user.id, deviceToken })); + reply.send(await service.create({ deviceToken, userId: user.id })); }; export default addUserDevice; diff --git a/packages/firebase/src/model/userDevice/handlers/removeUserDevice.ts b/packages/firebase/src/model/userDevice/handlers/removeUserDevice.ts index 2b193124d..f5f8504fb 100644 --- a/packages/firebase/src/model/userDevice/handlers/removeUserDevice.ts +++ b/packages/firebase/src/model/userDevice/handlers/removeUserDevice.ts @@ -1,8 +1,8 @@ -import Service from "../service"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import Service from "../service"; + const removeUserDevice = async ( request: SessionRequest, reply: FastifyReply, diff --git a/packages/firebase/src/model/userDevice/schema.ts b/packages/firebase/src/model/userDevice/schema.ts index 76df8f93a..6192dd943 100644 --- a/packages/firebase/src/model/userDevice/schema.ts +++ b/packages/firebase/src/model/userDevice/schema.ts @@ -1,32 +1,32 @@ const userDeviceSchema = { - type: "object", properties: { - userId: { type: "string" }, - deviceToken: { type: "string" }, createdAt: { type: "number" }, + deviceToken: { type: "string" }, updatedAt: { type: "number" }, + userId: { type: "string" }, }, required: ["userId", "deviceToken", "createdAt", "updatedAt"], + type: "object", }; export const deleteUserDeviceSchema = { - description: "Delete a user device by device token", - operationId: "deleteUserDevice", body: { - type: "object", properties: { deviceToken: { type: "string" }, }, required: ["deviceToken"], + type: "object", }, + description: "Delete a user device by device token", + operationId: "deleteUserDevice", response: { 200: { ...userDeviceSchema, nullable: true, }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -36,23 +36,23 @@ export const deleteUserDeviceSchema = { }; export const postUserDeviceSchema = { - description: "Register a new user device", - operationId: "postUserDevice", body: { - type: "object", properties: { deviceToken: { type: "string" }, }, required: ["deviceToken"], + type: "object", }, + description: "Register a new user device", + operationId: "postUserDevice", response: { 200: { ...userDeviceSchema, nullable: true, }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/firebase/src/model/userDevice/service.ts b/packages/firebase/src/model/userDevice/service.ts index ae93d4bea..8e079d36c 100644 --- a/packages/firebase/src/model/userDevice/service.ts +++ b/packages/firebase/src/model/userDevice/service.ts @@ -1,18 +1,26 @@ import { BaseService } from "@prefabs.tech/fastify-slonik"; -import UserDeviceSqlFactory from "./sqlFactory"; import { UserDevice, UserDeviceCreateInput, UserDeviceUpdateInput, } from "../../types"; +import UserDeviceSqlFactory from "./sqlFactory"; class UserDeviceService extends BaseService< UserDevice, UserDeviceCreateInput, UserDeviceUpdateInput > { - async getByUserId(userId: string): Promise { + get factory(): UserDeviceSqlFactory { + return super.factory as UserDeviceSqlFactory; + } + + get sqlFactoryClass() { + return UserDeviceSqlFactory; + } + + async getByUserId(userId: string): Promise { const query = this.factory.getFindByUserIdSql(userId); const result = await this.database.connect((connection) => { @@ -24,7 +32,7 @@ class UserDeviceService extends BaseService< async removeByDeviceToken( deviceToken: string, - ): Promise { + ): Promise { const query = this.factory.getDeleteExistingTokenSql(deviceToken); const result = await this.database.connect((connection) => { @@ -34,14 +42,6 @@ class UserDeviceService extends BaseService< return result; } - get factory(): UserDeviceSqlFactory { - return super.factory as UserDeviceSqlFactory; - } - - get sqlFactoryClass() { - return UserDeviceSqlFactory; - } - protected async preCreate( data: UserDeviceCreateInput, ): Promise { diff --git a/packages/firebase/src/model/userDevice/sqlFactory.ts b/packages/firebase/src/model/userDevice/sqlFactory.ts index 6fe663bd4..25f10ecca 100644 --- a/packages/firebase/src/model/userDevice/sqlFactory.ts +++ b/packages/firebase/src/model/userDevice/sqlFactory.ts @@ -6,6 +6,10 @@ import { TABLE_USER_DEVICES } from "../../constants"; class UserDeviceSqlFactory extends DefaultSqlFactory { static readonly TABLE = TABLE_USER_DEVICES; + get table() { + return this.config.firebase.table?.userDevices?.name || super.table; + } + getDeleteExistingTokenSql(token: string): QuerySqlToken { return sql.type(this.validationSchema)` DELETE @@ -22,10 +26,6 @@ class UserDeviceSqlFactory extends DefaultSqlFactory { ${this.getWhereFragment({ filterFragment: sql.fragment`user_id = ${userId}` })}; `; } - - get table() { - return this.config.firebase.table?.userDevices?.name || super.table; - } } export default UserDeviceSqlFactory; diff --git a/packages/firebase/src/plugin.ts b/packages/firebase/src/plugin.ts index 527cffbce..e6e9e6d77 100644 --- a/packages/firebase/src/plugin.ts +++ b/packages/firebase/src/plugin.ts @@ -1,3 +1,5 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { initializeFirebase } from "./lib"; @@ -5,10 +7,8 @@ import runMigrations from "./migrations/runMigrations"; import notificationRoutes from "./model/notification/controller"; import userDevicesRoutes from "./model/userDevice/controller"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance) => { - const { config, slonik, log } = fastify; + const { config, log, slonik } = fastify; if (config.firebase.enabled === false) { log.info("fastify-firebase plugin is not enabled"); diff --git a/packages/firebase/src/types.ts b/packages/firebase/src/types.ts index 9c4910c51..65b7d49c9 100644 --- a/packages/firebase/src/types.ts +++ b/packages/firebase/src/types.ts @@ -1,23 +1,23 @@ import "@prefabs.tech/fastify-error-handler"; -interface UserDevice { +interface TestNotificationInput { + body: string; + data?: { + [key: string]: string; + }; + title: string; userId: string; - deviceToken: string; - createdAt: number; - updatedAt: number; } interface User { id: string; } -interface TestNotificationInput { +interface UserDevice { + createdAt: number; + deviceToken: string; + updatedAt: number; userId: string; - title: string; - body: string; - data?: { - [key: string]: string; - }; } type UserDeviceCreateInput = Partial< @@ -25,7 +25,7 @@ type UserDeviceCreateInput = Partial< >; type UserDeviceUpdateInput = Partial< - Omit + Omit >; export type { diff --git a/packages/firebase/tsconfig.json b/packages/firebase/tsconfig.json index 8a8ad62d0..1628077b9 100644 --- a/packages/firebase/tsconfig.json +++ b/packages/firebase/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "./dist", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/firebase/vite.config.ts b/packages/firebase/vite.config.ts index 61b6f897a..76412931e 100644 --- a/packages/firebase/vite.config.ts +++ b/packages/firebase/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -27,8 +26,8 @@ export default defineConfig(({ mode }) => { globals: { "@prefabs.tech/fastify-error-handler": "PrefabsTechFastifyErrorHandler", - "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", + "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", "firebase-admin": "FirebaseAdmin", diff --git a/packages/graphql/FEATURES.md b/packages/graphql/FEATURES.md new file mode 100644 index 000000000..b7e42b94b --- /dev/null +++ b/packages/graphql/FEATURES.md @@ -0,0 +1,51 @@ + + +# @prefabs.tech/fastify-graphql — Features + +## Plugin Registration + +1. **Registration lifecycle logging** — the plugin logs `"Registering fastify-graphql plugin"` when registration starts. It logs a warning when using config fallback and logs `"GraphQL API not enabled"` when `enabled` is falsy. + +2. **Conditional mercurius registration** — when `enabled` is falsy, mercurius is not registered and `"GraphQL API not enabled"` is logged; the Fastify instance continues without a `/graphql` endpoint. + +3. **Config fallback** — when options object is empty (no options passed directly to `register()`), the plugin reads `fastify.config.graphql` and uses that as the options. Logs a warning when falling back. Throws `"Missing graphql configuration"` if `fastify.config.graphql` is also undefined. + +## Context Building + +4. **Default context factory** — when mercurius is registered, the plugin sets `context: buildContext(options.plugins)` first, then spreads `...options`. If a caller passes `options.context`, that caller-provided context overrides the default factory. + +5. **Automatic context building (default path)** — when the default context factory is used, each GraphQL request gets `config` (from `request.config`), `database` (from `request.slonik`), and `dbSchema` (from `request.dbSchema`) injected into Mercurius context. + +6. **Plugin-based context extension** — with the default context factory, plugins listed in `options.plugins` have `updateContext(context, request, reply)` called per-request, in array order, so each plugin can extend the shared Mercurius context. + +## Types & Module Augmentation + +7. **`GraphqlConfig` interface** — extends `MercuriusOptions` with two additional fields: `enabled?: boolean` and `plugins?: GraphqlEnabledPlugin[]`. + +8. **`GraphqlOptions` type alias** — exported alias for `GraphqlConfig`. + +9. **`GraphqlEnabledPlugin` interface** — a type that extends both `FastifyPluginAsync` and `FastifyPluginCallback`, plus carries an `updateContext(context: MercuriusContext, request: FastifyRequest, reply: FastifyReply): Promise` method required by the context extension system. + +10. **`MercuriusContext` augmentation** — adds typed `config: ApiConfig`, `database: Database`, and `dbSchema: string` to the global `mercurius` module's `MercuriusContext` interface. + +11. **`ApiConfig` augmentation** — adds `graphql: GraphqlConfig` to `@prefabs.tech/fastify-config`'s `ApiConfig` interface, making `fastify.config.graphql` fully typed. + +## Built-in Schema + +12. **`baseSchema` export** — a `DocumentNode` (parsed with `gql`) containing ready-to-merge GraphQL definitions: + - `@auth(profileValidation: Boolean, emailVerification: Boolean)` directive on `OBJECT | FIELD_DEFINITION` + - `@hasPermission(permission: String!)` directive on `OBJECT | FIELD_DEFINITION` + - `DateTime` scalar + - `JSON` scalar + - `Filters` input — recursive `AND: [Filters]`, `OR: [Filters]`, `not: Boolean`, `key: String`, `operator: String`, `value: String` + - `SortDirection` enum — `ASC | DESC` + - `SortInput` input — `key: String`, `direction: SortDirection` + - `DeleteResult` type — `result: Boolean!` + +## Re-exports + +13. **`mergeTypeDefs`** re-exported from `@graphql-tools/merge` — merges multiple `DocumentNode` or string schemas into one `DocumentNode`. + +14. **`gql`** tag re-exported from `graphql-tag` — parses GraphQL template literals into `DocumentNode`. + +15. **`DocumentNode`** type re-exported from `graphql`. diff --git a/packages/graphql/GUIDE.md b/packages/graphql/GUIDE.md new file mode 100644 index 000000000..b57d256ae --- /dev/null +++ b/packages/graphql/GUIDE.md @@ -0,0 +1,566 @@ +# @prefabs.tech/fastify-graphql — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-graphql + +# pnpm +pnpm add @prefabs.tech/fastify-graphql +``` + +Peer dependencies you must also install: + +```bash +pnpm add fastify fastify-plugin graphql mercurius \ + @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik \ + slonik zod +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# from repo root +pnpm install + +# run tests for this package only +pnpm --filter @prefabs.tech/fastify-graphql test + +# build +pnpm --filter @prefabs.tech/fastify-graphql build +``` + +--- + +## Setup + +Register the plugin once, after `@prefabs.tech/fastify-config` and `@prefabs.tech/fastify-slonik` (the context builder reads from request decorators those plugins add). + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import graphqlPlugin, { + baseSchema, + mergeTypeDefs, + gql, +} from "@prefabs.tech/fastify-graphql"; +import Fastify from "fastify"; + +const resolvers = { + Query: { + ping: async () => "pong", + }, +}; + +const schema = mergeTypeDefs([ + baseSchema, + gql` + type Query { + ping: String + } + `, +]); + +const fastify = Fastify({ logger: true }); + +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin, config.slonik); +await fastify.register(graphqlPlugin, { + enabled: true, + schema, + resolvers, +}); + +await fastify.listen({ port: 3000 }); +``` + +All later examples assume the Fastify instance is set up as above. + +--- + +## Base Libraries + +### mercurius — Partial Passthrough + +Docs: https://mercurius.dev / https://www.npmjs.com/package/mercurius + +The plugin wraps `mercurius` and passes the full options object through to `mercurius.register()`. We add two extra option fields (`enabled` and `plugins`) and provide a default context-building function. Because options are spread after that default, a caller-provided `context` still takes precedence. + +What we add on top: + +- The `enabled` guard — mercurius is only registered when `enabled` is truthy. +- A config-fallback path — reads `fastify.config.graphql` when no options are provided directly. +- A `buildContext` factory that seeds the Mercurius context with `config`, `database`, and `dbSchema`, then calls each `GraphqlEnabledPlugin.updateContext` in order. + +### @graphql-tools/merge — Full Passthrough + +Docs: https://the-guild.dev/graphql/tools/docs/schema-merging / https://www.npmjs.com/package/@graphql-tools/merge + +`mergeTypeDefs` is re-exported unchanged. No modifications. + +### graphql-tag — Full Passthrough + +Docs: https://www.npmjs.com/package/graphql-tag + +`gql` is re-exported unchanged. No modifications. + +### graphql — Type Re-export Only + +Docs: https://graphql.org/graphql-js/ / https://www.npmjs.com/package/graphql + +Only the `DocumentNode` type is re-exported. No runtime code from this library is executed by us. + +--- + +## Features + +### 1. Registration lifecycle logging + +The plugin emits lifecycle logs during setup so registration mode is visible in startup logs: + +- `info`: `"Registering fastify-graphql plugin"` when registration begins +- `warn`: fallback warning when no options are passed directly +- `info`: `"GraphQL API not enabled"` when `enabled` is falsy + +```typescript +await fastify.register(graphqlPlugin, config.graphql); +// logs: "Registering fastify-graphql plugin" +``` + +### 2. Conditional mercurius registration + +When `enabled` is falsy, the plugin logs `"GraphQL API not enabled"` and returns without registering mercurius. No `/graphql` route is mounted. + +```typescript +await fastify.register(graphqlPlugin, { + enabled: process.env.GRAPHQL_ENABLED !== "false", + schema, + resolvers, +}); +// When enabled is false, POST /graphql → 404 +``` + +### 3. Config fallback + +When the options object passed to `register()` is empty, the plugin falls back to `fastify.config.graphql`. It logs a deprecation-style warning. If `fastify.config.graphql` is also not present, it throws: + +> `"Missing graphql configuration. Did you forget to pass it to the graphql plugin?"` + +```typescript +// Direct options (preferred): +await fastify.register(graphqlPlugin, config.graphql); + +// Config fallback (legacy — triggers a warn log): +// fastify.config.graphql must be set by @prefabs.tech/fastify-config +await fastify.register(graphqlPlugin); +``` + +### 4. Default context factory + +When mercurius is registered, this package sets `context: buildContext(options.plugins)` as the default context function. Because `...options` is spread afterward, a caller-provided `context` can override this default. Use `plugins` when you want to extend the package-provided context path. + +```typescript +await fastify.register(graphqlPlugin, { + enabled: true, + schema, + resolvers, + plugins: [myContextPlugin], // preferred extension point + // context: customContext // optional override if you need full control +}); +``` + +### 5. Automatic context building (default path) + +When using the package's default context factory, three fields are injected into the Mercurius context from the Fastify request object automatically — no resolver-level wiring required. + +| Context field | Source | +| ------------------ | -------------------------------------------------------- | +| `context.config` | `request.config` (from `@prefabs.tech/fastify-config`) | +| `context.database` | `request.slonik` (from `@prefabs.tech/fastify-slonik`) | +| `context.dbSchema` | `request.dbSchema` (from `@prefabs.tech/fastify-slonik`) | + +```typescript +const resolvers = { + Query: { + user: async (_parent, { id }, context) => { + // context.config, context.database, context.dbSchema are ready to use + return context.database.pool.one( + context.database.sql` + SELECT * FROM ${context.database.sql.identifier([context.dbSchema, "users"])} + WHERE id = ${id} + `, + ); + }, + }, +}; +``` + +### 6. Plugin-based context extension + +Pass an array of `GraphqlEnabledPlugin` objects in the `plugins` option. When the default context factory is active, each plugin's `updateContext(context, request, reply)` method is called on every GraphQL request, in array order, after the base context is built. Plugins can add any fields they need to the shared context. + +```typescript +import type { FastifyInstance } from "fastify"; +import type { MercuriusContext } from "mercurius"; +import FastifyPlugin from "fastify-plugin"; +import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; + +// Augment the MercuriusContext type so resolvers get type safety +declare module "mercurius" { + interface MercuriusContext { + currentUser: User | null; + } +} + +const authPlugin = FastifyPlugin(async (fastify: FastifyInstance) => { + // Fastify-level setup (decorators, hooks, etc.) +}) as unknown as GraphqlEnabledPlugin; + +// Called once per GraphQL request +authPlugin.updateContext = async (context, request, _reply) => { + context.currentUser = request.user ?? null; +}; + +export default authPlugin; +``` + +Register it: + +```typescript +await fastify.register(graphqlPlugin, { + enabled: true, + plugins: [authPlugin], + schema, + resolvers, +}); +``` + +### 7. `GraphqlConfig` interface + +Extends `MercuriusOptions` with two plugin-specific fields: + +```typescript +import type { GraphqlConfig } from "@prefabs.tech/fastify-graphql"; + +const graphqlConfig: GraphqlConfig = { + enabled: true, // our addition — guards mercurius registration + plugins: [authPlugin], // our addition — context-extending plugins + schema, // passed through to mercurius + resolvers, // passed through to mercurius + graphiql: false, // passed through to mercurius +}; +``` + +### 8. `GraphqlOptions` type alias + +`GraphqlOptions` is an alias for `GraphqlConfig`. Use whichever name feels more natural in your codebase. + +```typescript +import type { GraphqlOptions } from "@prefabs.tech/fastify-graphql"; + +const opts: GraphqlOptions = { enabled: true, schema, resolvers }; +``` + +### 9. `GraphqlEnabledPlugin` interface + +A Fastify plugin (sync or async) that also carries a mandatory `updateContext` method. Implement this interface to create plugins that extend the GraphQL request context. + +```typescript +import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; +import type { MercuriusContext } from "mercurius"; +import FastifyPlugin from "fastify-plugin"; + +const myPlugin = FastifyPlugin(async (fastify) => { + fastify.decorate("myService", new MyService()); +}) as unknown as GraphqlEnabledPlugin; + +myPlugin.updateContext = async (context: MercuriusContext, request, reply) => { + context.myValue = await computeSomething(request); +}; +``` + +### 10. `MercuriusContext` augmentation + +This package extends the global `mercurius` module's `MercuriusContext` interface with three typed fields. Importing the package is enough for TypeScript to pick up the augmentation in your resolvers. + +```typescript +// Automatically available after importing the package: +// context.config → ApiConfig +// context.database → Database +// context.dbSchema → string + +const resolvers = { + Query: { + appInfo: (_parent, _args, context) => ({ + name: context.config.appName, // typed as string + env: context.config.env, // typed as string + }), + }, +}; +``` + +### 11. `ApiConfig` augmentation + +Importing this package extends `@prefabs.tech/fastify-config`'s `ApiConfig` interface with a `graphql` property typed as `GraphqlConfig`. This allows `fastify.config.graphql` to be fully typed throughout your application. + +```typescript +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +// After importing @prefabs.tech/fastify-graphql, ApiConfig gains: +// graphql: GraphqlConfig + +const config: ApiConfig = { + graphql: { + enabled: true, + schema, + resolvers, + }, + // ... other fields +}; +``` + +### 12. `baseSchema` export + +A pre-built `DocumentNode` with common GraphQL primitives ready to merge into your application schema. Use `mergeTypeDefs` to combine it with your own type definitions. + +```typescript +import { baseSchema, mergeTypeDefs, gql } from "@prefabs.tech/fastify-graphql"; +import { makeExecutableSchema } from "@graphql-tools/schema"; + +const appTypeDefs = gql` + type Query { + users(filters: Filters, sort: [SortInput]): [User!]! + deleteUser(id: ID!): DeleteResult + } + + type User { + id: ID! + email: String! + createdAt: DateTime! + metadata: JSON + } +`; + +const schema = makeExecutableSchema({ + typeDefs: mergeTypeDefs([baseSchema, appTypeDefs]), +}); +``` + +Provided by `baseSchema`: + +| Name | Kind | Description | +| ---------------- | --------- | ---------------------------------------------------------------------------------------- | +| `@auth` | directive | `profileValidation: Boolean`, `emailVerification: Boolean` on OBJECT or FIELD_DEFINITION | +| `@hasPermission` | directive | `permission: String!` on OBJECT or FIELD_DEFINITION | +| `DateTime` | scalar | Date/time values | +| `JSON` | scalar | Arbitrary JSON values | +| `Filters` | input | Recursive `AND`/`OR` filter tree with `key`, `operator`, `value` | +| `SortDirection` | enum | `ASC` or `DESC` | +| `SortInput` | input | `key: String`, `direction: SortDirection` | +| `DeleteResult` | type | `result: Boolean!` | + +### 13. `mergeTypeDefs` re-export + +`mergeTypeDefs` from `@graphql-tools/merge` is re-exported so you do not need a separate import. Merges an array of `DocumentNode` objects or SDL strings into a single `DocumentNode`. + +```typescript +import { baseSchema, mergeTypeDefs, gql } from "@prefabs.tech/fastify-graphql"; + +const merged = mergeTypeDefs([ + baseSchema, + gql` + type Query { + ping: String + } + `, + `type Mutation { noop: Boolean }`, +]); +``` + +### 14. `gql` tag re-export + +`gql` from `graphql-tag` is re-exported so you do not need a separate import. Parses a tagged template literal into a `DocumentNode`. + +```typescript +import { gql } from "@prefabs.tech/fastify-graphql"; + +const userTypeDefs = gql` + type User { + id: ID! + email: String! + } +`; +``` + +### 15. `DocumentNode` type re-export + +The `DocumentNode` type from the `graphql` package is re-exported for use in function signatures and type annotations without adding a direct `graphql` dependency to your code. + +```typescript +import type { DocumentNode } from "@prefabs.tech/fastify-graphql"; + +function buildSchema(extra: DocumentNode[]): DocumentNode { + return mergeTypeDefs([baseSchema, ...extra]); +} +``` + +--- + +## Use Cases + +### Use Case 1: Minimal GraphQL API + +Stand up a GraphQL endpoint with just `enabled`, `schema`, and `resolvers`. + +```typescript +import graphqlPlugin, { gql } from "@prefabs.tech/fastify-graphql"; +import Fastify from "fastify"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(graphqlPlugin, { + enabled: true, + schema: gql` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: async () => "world", + }, + }, +}); + +await fastify.listen({ port: 3000 }); +``` + +### Use Case 2: Full app setup with config, database, and auth context + +Realistic production setup: config plugin seeds `fastify.config`, slonik plugin seeds `request.slonik` and `request.dbSchema`, a custom auth plugin extends context with the current user. + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import graphqlPlugin, { + baseSchema, + mergeTypeDefs, + gql, + type GraphqlEnabledPlugin, +} from "@prefabs.tech/fastify-graphql"; +import FastifyPlugin from "fastify-plugin"; +import Fastify from "fastify"; +import type { MercuriusContext } from "mercurius"; + +// --- Auth plugin --- +declare module "mercurius" { + interface MercuriusContext { + currentUser: User | null; + } +} + +const authPlugin = FastifyPlugin(async (fastify) => { + fastify.addHook("onRequest", async (request) => { + // decode JWT, set request.user + }); +}) as unknown as GraphqlEnabledPlugin; + +authPlugin.updateContext = async (context, request) => { + context.currentUser = request.user ?? null; +}; + +// --- Schema & resolvers --- +const appTypeDefs = gql` + type Query { + me: User + users(filters: Filters): [User!]! + } + + type User { + id: ID! + email: String! + createdAt: DateTime! + } +`; + +const resolvers = { + Query: { + me: (_parent, _args, context) => context.currentUser, + users: async (_parent, { filters }, context) => + userService.find(context.database, context.dbSchema, filters), + }, +}; + +// --- App bootstrap --- +const fastify = Fastify({ logger: config.logger }); + +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin, config.slonik); +await fastify.register(authPlugin); +await fastify.register(graphqlPlugin, { + ...config.graphql, + schema: mergeTypeDefs([baseSchema, appTypeDefs]), + resolvers, + plugins: [authPlugin], +}); + +await fastify.listen({ port: config.port }); +``` + +### Use Case 3: Feature-flag GraphQL off in non-production environments + +```typescript +const graphqlConfig = { + enabled: process.env.GRAPHQL_ENABLED === "true", + schema, + resolvers, +}; + +await fastify.register(graphqlPlugin, graphqlConfig); +// When GRAPHQL_ENABLED is not "true", /graphql returns 404 +``` + +### Use Case 4: Composing schemas from multiple modules + +Each feature module exports its own type definitions and resolvers, merged at startup. + +```typescript +import { baseSchema, mergeTypeDefs, gql } from "@prefabs.tech/fastify-graphql"; +import { makeExecutableSchema } from "@graphql-tools/schema"; + +import { + typeDefs as userTypeDefs, + resolvers as userResolvers, +} from "./modules/user"; +import { + typeDefs as orderTypeDefs, + resolvers as orderResolvers, +} from "./modules/order"; + +const schema = makeExecutableSchema({ + typeDefs: mergeTypeDefs([baseSchema, userTypeDefs, orderTypeDefs]), + resolvers: [userResolvers, orderResolvers], +}); + +await fastify.register(graphqlPlugin, { enabled: true, schema }); +``` + +### Use Case 5: Multiple context-extending plugins in order + +```typescript +import { authPlugin } from "./plugins/auth"; +import { tenantPlugin } from "./plugins/tenant"; +import { auditPlugin } from "./plugins/audit"; + +await fastify.register(graphqlPlugin, { + enabled: true, + schema, + resolvers, + // Each plugin's updateContext runs in this order on every request: + plugins: [authPlugin, tenantPlugin, auditPlugin], +}); +``` + +Each plugin receives the already-built context, so `tenantPlugin.updateContext` can read `context.currentUser` set by `authPlugin`, and `auditPlugin` can read both. diff --git a/packages/graphql/README.md b/packages/graphql/README.md index 5711f203d..350f3f0b5 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -4,12 +4,24 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy int The plugin is a thin wrapper around the [mercurius](https://mercurius.dev/#/) plugin. +## Why this plugin? + +While registering `mercurius` directly perfectly enables GraphQL in a Fastify backend, enterprise APIs require deep context injection—such as database connections and application configurations—to function effectively within resolvers. We created this plugin to: + +- **Automate Context Injection**: Instead of manually building context objects on every request, this plugin automatically populates the `MercuriusContext` with the `fastify.config`, `slonik` database connection, and `dbSchema`, making them instantly and safely available to all your GraphQL resolvers out-of-the-box. +- **Unify Configuration**: By integrating seamlessly with `@prefabs.tech/fastify-config`, it ensures that your GraphQL schema paths, options, and boolean flags are strictly typed and managed in one centralized location. + +### Design Decisions: Why not Apollo Server or bare Mercurius? + +- **Why Mercurius instead of Apollo**: Mercurius is specifically built for Fastify, leveraging Fastify's lifecycle hooks to deliver significantly better performance and lower latency than typical Apollo setups. +- **Why intercept Mercurius**: Using bare Mercurius means maintaining your own context factory methods to inject database connections and app configurations recursively. By wrapping it, we enforce a standard, highly-typed context shape that is guaranteed to match the rest of our ecosystem, eliminating setup boilerplate completely. + ## Requirements -* [@prefabs.tech/fastify-config](../config/) -* [@prefabs.tech/fastify-slonik](../slonik/) -* [graphql](https://github.com/graphql/graphql-js) -* [mercurius](https://mercurius.dev/#/) +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [graphql](https://github.com/graphql/graphql-js) +- [mercurius](https://mercurius.dev/#/) ## Installation @@ -26,6 +38,7 @@ pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fa ``` ## Usage + To set up graphql in fastify project, follow these steps: Create a resolvers file at `src/graphql/resolvers.ts` to define all GraphQL mutations and queries. @@ -134,19 +147,20 @@ An additional `enabled` (boolean) option allows you to disable the graphql serve The fastify-graphql plugin will generate a graphql context on every request that will include the following attributes: -| Attribute | Type | Description | -|------------|------|-------------| -| `config` | `ApiConfig` | The fastify servers' config (as per [@prefabs.tech/fastify-config](../config/)) | +| Attribute | Type | Description | +| ---------- | ----------- | ---------------------------------------------------------------------------------------- | +| `config` | `ApiConfig` | The fastify servers' config (as per [@prefabs.tech/fastify-config](../config/)) | | `database` | `Database` | The fastify server's slonik instance (as per [@prefabs.tech/fastify-slonik](../slonik/)) | -| `dbSchema` | `string` | The database schema (as per [@prefabs.tech/fastify-slonik](../slonik/)) | +| `dbSchema` | `string` | The database schema (as per [@prefabs.tech/fastify-slonik](../slonik/)) | ## Supporting `.gql` files and external schema exports - To work with multiple schemas defined in `.gql` files or support GraphQL schema exports from external packages, ensure the following packages are installed in your API: -* [@graphql-tools/load](https://github.com/ardatan/graphql-tools/tree/master/packages/load) -* [@graphql-tools/load-files](https://github.com/ardatan/graphql-tools/tree/master/packages/load-files) -* [@graphql-tools/merge](https://github.com/ardatan/graphql-tools/tree/master/packages/merge) -* [@graphql-tools/schema](https://github.com/ardatan/graphql-tools/tree/master/packages/schema) +To work with multiple schemas defined in `.gql` files or support GraphQL schema exports from external packages, ensure the following packages are installed in your API: + +- [@graphql-tools/load](https://github.com/ardatan/graphql-tools/tree/master/packages/load) +- [@graphql-tools/load-files](https://github.com/ardatan/graphql-tools/tree/master/packages/load-files) +- [@graphql-tools/merge](https://github.com/ardatan/graphql-tools/tree/master/packages/merge) +- [@graphql-tools/schema](https://github.com/ardatan/graphql-tools/tree/master/packages/schema) To load and merge your GraphQL schemas, update your `src/graphql/schema.ts` file as follows: @@ -164,6 +178,7 @@ export default schema; ``` If you also need to include schemas defined in other packages update above code: + ```typescript import { graphqlSchema } from "example"; // example: importing schemas from external packages import { loadFilesSync } from "@graphql-tools/load-files"; @@ -177,6 +192,7 @@ const schema = makeExecutableSchema({ typeDefs }); export default schema; ``` + You can define additional schemas within the `src/` directory, including any nested subdirectories, using `.gql` files. For example, create a new file at `src/graphql/schema.gql`: ```graphql diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 3e0931a18..af041f437 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-graphql", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify graphql plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/graphql#readme", "repository": { @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-graphql.cjs", "module": "./dist/prefabs-tech-fastify-graphql.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -29,32 +31,32 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { - "@graphql-tools/merge": "9.1.7", + "@graphql-tools/merge": "9.1.8", "graphql-tag": "2.12.6" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/fastify-slonik": "0.93.5", - "@prefabs.tech/tsconfig": "0.5.0", - "@types/node": "24.10.13", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/fastify-slonik": "0.94.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@types/node": "24.10.15", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "graphql": "16.12.0", - "mercurius": "16.7.0", - "prettier": "3.8.1", + "graphql": "16.13.2", + "mercurius": "16.9.0", + "prettier": "3.8.3", "slonik": "46.8.0", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4", "zod": "3.25.76" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/fastify-slonik": "0.93.5", - "fastify": ">=5.2.1", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/fastify-slonik": "0.94.0", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1", "graphql": ">=16.9.0", "mercurius": ">=16.1.0", @@ -64,4 +66,4 @@ "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/graphql/src/__test__/baseSchema.test.ts b/packages/graphql/src/__test__/baseSchema.test.ts new file mode 100644 index 000000000..ba84b8061 --- /dev/null +++ b/packages/graphql/src/__test__/baseSchema.test.ts @@ -0,0 +1,19 @@ +import { print } from "graphql"; +import { describe, expect, it } from "vitest"; + +import baseSchema from "../baseSchema"; + +describe("baseSchema", () => { + it("includes documented directives, scalars, inputs, enum, and types", () => { + const printed = print(baseSchema); + + expect(printed).toContain("directive @auth"); + expect(printed).toContain("directive @hasPermission"); + expect(printed).toContain("scalar DateTime"); + expect(printed).toContain("scalar JSON"); + expect(printed).toContain("input Filters"); + expect(printed).toContain("enum SortDirection"); + expect(printed).toContain("input SortInput"); + expect(printed).toContain("type DeleteResult"); + }); +}); diff --git a/packages/graphql/src/__test__/context.spec.ts b/packages/graphql/src/__test__/context.spec.ts index def36cb9c..3d524c9c7 100644 --- a/packages/graphql/src/__test__/context.spec.ts +++ b/packages/graphql/src/__test__/context.spec.ts @@ -1,18 +1,22 @@ -import fastify from "fastify"; -import { describe, expect, it, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import graphqlPlugin from "../plugin"; import createConfig from "./helpers/createConfig"; import testPlugin from "./helpers/testPlugin"; import testPluginAsync from "./helpers/testPluginAsync"; -import type { FastifyInstance } from "fastify"; - -describe("Graphql Context", async () => { +describe("Graphql Context", () => { let api: FastifyInstance; - beforeEach(async () => { - api = await fastify(); + beforeEach(() => { + api = Fastify({ logger: false }); + }); + + afterEach(async () => { + await api.close(); }); it("Should add context property and value from callback test plugin", async () => { @@ -79,9 +83,9 @@ describe("Graphql Context", async () => { }); expect(JSON.parse(response.payload).data.test).toEqual({ + propertyOne: "Property One", //eslint-disable-next-line unicorn/no-null propertyTwo: null, - propertyOne: "Property One", }); expect(api).toHaveProperty(["propertyOne"], "Property One"); @@ -115,8 +119,8 @@ describe("Graphql Context", async () => { }); expect(JSON.parse(response.payload).data.test).toEqual({ - propertyTwo: "Property Two", propertyOne: "Property One", + propertyTwo: "Property Two", }); expect(api).toHaveProperty(["propertyTwo"], "Property Two"); diff --git a/packages/graphql/src/__test__/helpers/createConfig.ts b/packages/graphql/src/__test__/helpers/createConfig.ts index ed8927194..7b20d3bae 100644 --- a/packages/graphql/src/__test__/helpers/createConfig.ts +++ b/packages/graphql/src/__test__/helpers/createConfig.ts @@ -1,8 +1,9 @@ -/* istanbul ignore file */ -import type { GraphqlEnabledPlugin } from "../../types"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { MercuriusContext } from "mercurius"; +/* istanbul ignore file */ +import type { GraphqlEnabledPlugin } from "../../types"; + const schema = ` type Query { test: Response @@ -29,6 +30,14 @@ const createConfig = (plugins: GraphqlEnabledPlugin[]) => { appOrigin: ["http://localhost"], baseUrl: "http://localhost", env: "development", + graphql: { + enabled: true, + graphiql: false, + path: "/graphql", + plugins, + resolvers, + schema, + }, logger: { level: "debug", }, @@ -38,15 +47,6 @@ const createConfig = (plugins: GraphqlEnabledPlugin[]) => { rest: { enabled: true, }, - version: "0.1", - graphql: { - enabled: true, - graphiql: false, - path: "/graphql", - schema, - resolvers, - plugins, - }, slonik: { db: { databaseName: "test", @@ -55,6 +55,7 @@ const createConfig = (plugins: GraphqlEnabledPlugin[]) => { username: "username", }, }, + version: "0.1", }; return config; diff --git a/packages/graphql/src/__test__/helpers/testPlugin.ts b/packages/graphql/src/__test__/helpers/testPlugin.ts index 9f65d15bc..08bbbd01f 100644 --- a/packages/graphql/src/__test__/helpers/testPlugin.ts +++ b/packages/graphql/src/__test__/helpers/testPlugin.ts @@ -1,8 +1,9 @@ +import type { FastifyInstance } from "fastify"; +import type { MercuriusContext } from "mercurius"; + import FastifyPlugin from "fastify-plugin"; import type { GraphqlEnabledPlugin } from "../../types"; -import type { FastifyInstance } from "fastify"; -import type { MercuriusContext } from "mercurius"; declare module "mercurius" { interface MercuriusContext { diff --git a/packages/graphql/src/__test__/helpers/testPluginAsync.ts b/packages/graphql/src/__test__/helpers/testPluginAsync.ts index a2ab30601..a8fcaf1ec 100644 --- a/packages/graphql/src/__test__/helpers/testPluginAsync.ts +++ b/packages/graphql/src/__test__/helpers/testPluginAsync.ts @@ -1,8 +1,9 @@ +import type { FastifyInstance } from "fastify"; +import type { MercuriusContext } from "mercurius"; + import FastifyPlugin from "fastify-plugin"; import type { GraphqlEnabledPlugin } from "../../types"; -import type { FastifyInstance } from "fastify"; -import type { MercuriusContext } from "mercurius"; declare module "mercurius" { interface MercuriusContext { diff --git a/packages/graphql/src/__test__/plugin.test.ts b/packages/graphql/src/__test__/plugin.test.ts new file mode 100644 index 000000000..79e20b9e0 --- /dev/null +++ b/packages/graphql/src/__test__/plugin.test.ts @@ -0,0 +1,238 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import graphqlPlugin from "../plugin"; + +const schema = ` + type Query { + ping: String + } +`; + +const resolvers = { + Query: { + ping: async () => "pong", + }, +}; + +describe("graphqlPlugin — conditional registration", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("registers the /graphql route when enabled is true", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(graphqlPlugin, { enabled: true, resolvers, schema }); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ query: "{ ping }" }), + url: "/graphql", + }); + + expect(response.statusCode).toBe(200); + }); + + it("does not register /graphql route when enabled is false", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(graphqlPlugin, { + enabled: false, + resolvers, + schema, + }); + await fastify.ready(); + + const response = await fastify.inject({ + method: "POST", + url: "/graphql", + }); + + expect(response.statusCode).toBe(404); + }); + + it("does not register /graphql when enabled is omitted", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(graphqlPlugin, { resolvers, schema }); + await fastify.ready(); + + const response = await fastify.inject({ + method: "POST", + url: "/graphql", + }); + + expect(response.statusCode).toBe(404); + }); + + it("uses caller-provided context instead of the default factory when both are applicable", async () => { + fastify = Fastify({ logger: false }); + const customSchema = ` + type Query { + source: String + } + `; + const customResolvers = { + Query: { + source: async (_: unknown, __: unknown, context: unknown) => + (context as { source: string }).source, + }, + }; + + await fastify.register(graphqlPlugin, { + context: async () => ({ source: "custom-context" }), + enabled: true, + resolvers: customResolvers, + schema: customSchema, + }); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ query: "{ source }" }), + url: "/graphql", + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload).data.source).toBe("custom-context"); + }); + + it("injects config, database, and dbSchema from the request into Mercurius context by default", async () => { + fastify = Fastify({ logger: false }); + const dumpSchema = ` + type Query { + dump: String + } + `; + const dumpResolvers = { + Query: { + dump: async (_: unknown, __: unknown, context: unknown) => + JSON.stringify({ + config: (context as { config: { marker: string } }).config.marker, + database: (context as { database: { marker: string } }).database + .marker, + dbSchema: (context as { dbSchema: string }).dbSchema, + }), + }, + }; + + fastify.addHook("onRequest", async (request) => { + request.config = { marker: "cfg" } as unknown as typeof request.config; + request.slonik = { marker: "db" } as unknown as typeof request.slonik; + request.dbSchema = "app_schema"; + }); + + await fastify.register(graphqlPlugin, { + enabled: true, + resolvers: dumpResolvers, + schema: dumpSchema, + }); + await fastify.ready(); + + const response = await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ query: "{ dump }" }), + url: "/graphql", + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload).data.dump).toBe( + JSON.stringify({ + config: "cfg", + database: "db", + dbSchema: "app_schema", + }), + ); + }); +}); + +describe("graphqlPlugin — config fallback", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("reads options from fastify.config.graphql when no options are passed directly", async () => { + fastify = Fastify({ logger: false }); + fastify.decorate("config", { + graphql: { enabled: false, resolvers, schema }, + } as unknown as ApiConfig); + + await fastify.register(graphqlPlugin); + await fastify.ready(); + + // enabled: false in the fallback config means mercurius was not mounted + const response = await fastify.inject({ method: "POST", url: "/graphql" }); + expect(response.statusCode).toBe(404); + }); + + it("throws when no options are passed and fastify.config.graphql is undefined", async () => { + fastify = Fastify({ logger: false }); + fastify.decorate("config", {} as unknown as ApiConfig); + + const start = async () => { + await fastify.register(graphqlPlugin); + await fastify.ready(); + }; + + await expect(start()).rejects.toThrow("Missing graphql configuration"); + }); + + it("logs a warning when falling back to fastify.config.graphql", async () => { + fastify = Fastify({ logger: false }); + const warn = vi.spyOn(fastify.log, "warn").mockImplementation(() => {}); + fastify.decorate("config", { + graphql: { enabled: false, resolvers, schema }, + } as unknown as ApiConfig); + + await fastify.register(graphqlPlugin); + await fastify.ready(); + + expect(warn).toHaveBeenCalled(); + const warnedWithRecommendation = warn.mock.calls.some((call) => + call.some( + (argument) => + typeof argument === "string" && + argument.includes("passing graphql options directly"), + ), + ); + expect(warnedWithRecommendation).toBe(true); + }); +}); + +describe("graphqlPlugin — registration logging", () => { + let fastify!: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("logs that GraphQL is disabled when enabled is false", async () => { + fastify = Fastify({ logger: false }); + const info = vi.spyOn(fastify.log, "info").mockImplementation(() => {}); + + await fastify.register(graphqlPlugin, { + enabled: false, + resolvers, + schema, + }); + await fastify.ready(); + + const loggedDisabled = info.mock.calls.some((call) => + call.some( + (argument) => + typeof argument === "string" && + argument.includes("GraphQL API not enabled"), + ), + ); + expect(loggedDisabled).toBe(true); + }); +}); diff --git a/packages/graphql/src/buildContext.ts b/packages/graphql/src/buildContext.ts index b19772352..bcf1dfad1 100644 --- a/packages/graphql/src/buildContext.ts +++ b/packages/graphql/src/buildContext.ts @@ -1,7 +1,8 @@ -import type { GraphqlEnabledPlugin } from "./types"; -import type { FastifyRequest, FastifyReply } from "fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import type { MercuriusContext } from "mercurius"; +import type { GraphqlEnabledPlugin } from "./types"; + const buildContext = (plugins?: GraphqlEnabledPlugin[]) => { return async (request: FastifyRequest, reply: FastifyReply) => { const context = { diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 9d05a661f..540a46416 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -1,7 +1,8 @@ -import type { GraphqlConfig } from "./types"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import type { GraphqlConfig } from "./types"; + declare module "mercurius" { interface MercuriusContext { config: ApiConfig; @@ -16,14 +17,14 @@ declare module "@prefabs.tech/fastify-config" { } } -export { default } from "./plugin"; -export { gql } from "graphql-tag"; -export { mergeTypeDefs } from "@graphql-tools/merge"; export { default as baseSchema } from "./baseSchema"; - +export { default } from "./plugin"; export type { GraphqlConfig, GraphqlEnabledPlugin, GraphqlOptions, } from "./types"; +export { mergeTypeDefs } from "@graphql-tools/merge"; + export type { DocumentNode } from "graphql"; +export { gql } from "graphql-tag"; diff --git a/packages/graphql/src/plugin.ts b/packages/graphql/src/plugin.ts index 20fcc6037..ca8a2f0fc 100644 --- a/packages/graphql/src/plugin.ts +++ b/packages/graphql/src/plugin.ts @@ -1,10 +1,11 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { mercurius } from "mercurius"; -import buildContext from "./buildContext"; - import type { GraphqlOptions } from "./types"; -import type { FastifyInstance } from "fastify"; + +import buildContext from "./buildContext"; const plugin = async (fastify: FastifyInstance, options: GraphqlOptions) => { fastify.log.info("Registering fastify-graphql plugin"); diff --git a/packages/graphql/src/types.ts b/packages/graphql/src/types.ts index 8ce2c3544..be39b9d9a 100644 --- a/packages/graphql/src/types.ts +++ b/packages/graphql/src/types.ts @@ -1,8 +1,8 @@ import type { - FastifyPluginCallback, FastifyPluginAsync, - FastifyRequest, + FastifyPluginCallback, FastifyReply, + FastifyRequest, } from "fastify"; import type { MercuriusContext, MercuriusOptions } from "mercurius"; diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json index 50005d55b..1628077b9 100644 --- a/packages/graphql/tsconfig.json +++ b/packages/graphql/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { - "outDir": "./dist", + "baseUrl": "./", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/graphql/vite.config.ts b/packages/graphql/vite.config.ts index 6af9117f1..a002d82b7 100644 --- a/packages/graphql/vite.config.ts +++ b/packages/graphql/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -25,9 +24,9 @@ export default defineConfig(({ mode }) => { output: { exports: "named", globals: { + "@graphql-tools/merge": "GraphqlToolsMerge", "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", - "@graphql-tools/merge": "GraphqlToolsMerge", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", graphql: "Graphql", diff --git a/packages/mailer/ANALYSIS.md b/packages/mailer/ANALYSIS.md new file mode 100644 index 000000000..5ba176761 --- /dev/null +++ b/packages/mailer/ANALYSIS.md @@ -0,0 +1,116 @@ + + +# @prefabs.tech/fastify-mailer — Analysis + +## Base Library Passthrough Analysis + +### nodemailer — PARTIAL PASSTHROUGH + +- Options type: custom `MailerConfig` with `transport: SMTPOptions` and `defaults: { from } & Partial` +- Options passed: transformed before send + - `createTransport(transport, defaults)` is direct passthrough at registration time. + - Outgoing `sendMail` input is wrapped to merge template data and optionally override recipients. +- Features restricted: none at transport level; delivery payload is modified when `recipients` is configured. +- Features added: + - Fastify decorator (`fastify.mailer`) + - Global + per-email `templateData` merge + - Optional recipient redirection (`to` override, `cc`/`bcc` cleared) + - Optional callback-aware wrapper + +### nodemailer-mjml — PARTIAL PASSTHROUGH + +- Options type: `templating: IPluginOptions` in our config type +- Options passed: transformed — only `{ templateFolder: templating.templateFolder }` is forwarded to `nodemailerMjmlPlugin` +- Features restricted: other `IPluginOptions` keys are not forwarded +- Features added: integrated compile hook registration inside plugin startup + +### nodemailer-html-to-text — MODIFIED + +- Options type: none exposed +- Options passed: transformed — always called as `htmlToText()` with default options +- Features restricted: no way to configure plugin options from `MailerConfig` +- Features added: automatic compile hook registration after MJML compile hook + +### mjml — MODIFIED + +- Options type: none exposed +- Options passed: transformed — `mjml2html()` is used only in the optional test route with an inline MJML template string +- Features restricted: not configurable through plugin options +- Features added: built-in HTTP test endpoint that sends a compiled test email + +### fastify-plugin — FULL PASSTHROUGH + +- Options type: not applicable +- Options passed: unmodified plugin wrapper (`FastifyPlugin(plugin)`) +- Features restricted: none +- Features added: encapsulation bypass for decorated `fastify.mailer` + +## Function/Export Classification (Ours vs Theirs) + +- `default export from src/plugin.ts` (`FastifyPlugin(plugin)`) — **OURS** + - Our registration logic, decorators, wrappers, and conditionals are inside `plugin`. + - Wrapper call to `fastify-plugin` is third-party integration. +- `default export from src/index.ts` (re-export plugin) — **OURS** + - Public package entrypoint and module augmentation. +- `FastifyMailer` (type alias) — **OURS** (extends third-party `Transporter`) +- `FastifyMailerNamedInstance` (interface) — **OURS** +- `MailerConfig` / `MailerOptions` (config shape) — **OURS** (composes third-party option types) +- `router` (`src/router.ts`) — **OURS** + - Registers conditional test endpoint and response payload contract. +- `testEmailSchema` (`src/schema.ts`) — **OURS** + - Defines schema for test route responses and OpenAPI metadata. + +## Fastify Integrations + +### Decorators Added + +- `fastify.mailer` — decorated once after transport setup +- Guard: throws `"fastify-mailer has already been registered"` if already present + +### Hooks/Routes Registered + +- Nodemailer `"compile"` hook with `nodemailerMjmlPlugin({ templateFolder })` +- Nodemailer `"compile"` hook with `htmlToText()` +- Conditional Fastify `GET` route at `test.path` (when `test?.enabled`) + +## Conditional Branches + +- **Legacy config fallback** + - Condition: `Object.keys(options).length === 0` + - Behavior: warn + read `fastify.config.mailer` + - Error path: throws if `fastify.config.mailer` missing +- **Template data merge** + - Starts with empty object + - Merges global config `templateData` if present + - Merges per-email `userOptions.templateData` last (wins on key conflicts) +- **Recipient override** + - Condition: `recipients && recipients.length > 0` + - Behavior: force `to = recipients`, set `cc` and `bcc` to `undefined` +- **Callback path** + - Condition: callback provided to `sendMail` + - Behavior: calls `transporter.sendMail(mailerOptions, callback)`; otherwise promise path +- **Test route registration** + - Condition: `test && test.enabled` + - Behavior: register internal router with `{ path, to }` + +## Default Values and Implicit Defaults + +- `templateData` defaults to `{}` inside wrapped `sendMail` +- `cc` and `bcc` are explicitly set to `undefined` only in recipient-override mode +- Test route is disabled by default (when `test` is missing or `enabled` is false) +- `htmlToText()` runs with library defaults (no options passed) + +## Notes from Existing Docs and Tests + +- Existing `FEATURES.md` and `GUIDE.md` largely match implementation. +- One important nuance from source: `templating` is typed as `IPluginOptions`, but only `templateFolder` is currently forwarded. +- Tests verify registration flow, template-data merge precedence, recipient override behavior, and conditional test route behavior. + +## Completeness Checklist + +- [x] Classified every public export as "ours" or "theirs" +- [x] Listed every Fastify decorator added +- [x] Listed every hook registered +- [x] Identified every conditional branch +- [x] Documented default values for options we define +- [x] Produced passthrough classification for every wrapped dependency diff --git a/packages/mailer/FEATURES.md b/packages/mailer/FEATURES.md new file mode 100644 index 000000000..70bc81cbb --- /dev/null +++ b/packages/mailer/FEATURES.md @@ -0,0 +1,77 @@ + + +# @prefabs.tech/fastify-mailer — Features + +## Plugin Registration + +1. **Registration info log** — logs `info: Registering fastify-mailer plugin` on startup. + +2. **Duplicate registration guard** — throws `"fastify-mailer has already been registered"` if you register the plugin twice on the same Fastify instance. + +3. **Config fallback (legacy mode)** — when no options are passed to `register()`, reads config from `fastify.config.mailer` (requires `@prefabs.tech/fastify-config`). Emits a deprecation warning. + +4. **Missing config error** — when neither inline options nor `fastify.config.mailer` is present, throws a descriptive error: + + ``` + Error: Missing mailer configuration. Did you forget to pass it to the mailer plugin? + ``` + +5. **Fastify encapsulation bypass** — wrapped with `fastify-plugin` so `fastify.mailer` is available in all child plugins without re-registering. + +## Transport + +6. **SMTP transport creation** — calls `nodemailer.createTransport(transport, defaults)` to create the transporter. Compatible with any SMTP provider (AWS SES, Mailgun, SendGrid, Gmail, etc.). + +7. **Default sender via `defaults.from`** — `defaults.from.address` and `defaults.from.name` are applied as global sender defaults to every email. Additional nodemailer `Options` (e.g., `replyTo`) can also be set under `defaults`. + +## Compile-time Middleware + +8. **MJML compile hook** — registers `nodemailerMjmlPlugin` on the nodemailer `"compile"` lifecycle with the configured `templateFolder`. Enables `.mjml` template files to be resolved, compiled to HTML, and interpolated before delivery. + +9. **Auto HTML-to-text conversion** — registers `nodemailer-html-to-text` on the `"compile"` lifecycle after MJML. Automatically generates a plain-text `text` part from `html` for every email. + +## `fastify.mailer` Decorator + +10. **Transporter-backed `fastify.mailer` decorator** — `fastify.mailer` is built from the created nodemailer transporter and overrides `sendMail` with plugin-specific behavior (template data merge + optional recipient override). + +11. **Promise-based `sendMail`** — resolves with nodemailer's `SentMessageInfo`. + +12. **Callback-based `sendMail`** — accepts an optional Node.js-style callback as the second argument. + +## Template Data + +13. **Global template data** — set `templateData` in plugin options to provide variables available in every email template without passing them per-email. + +14. **Per-email template data** — pass `templateData` directly on each `sendMail` call for email-specific variables. + +15. **Template data merge with override precedence** — per-email `templateData` is shallow-merged over the global config `templateData`. Per-email values win on key conflicts. The global object is never mutated. + +## Recipient Override + +16. **Redirect all emails to fixed addresses** — when `recipients` is a non-empty array, every outgoing email is redirected to those addresses regardless of the `to` field. `cc` and `bcc` are explicitly cleared to `undefined`. + +## Test Infrastructure + +17. **Conditional HTTP test route** — when `test.enabled` is `true`, registers a `GET` route at `test.path` that sends a test email to `test.to`. Omit `test` or set `test.enabled: false` to skip. Route returns: + + ```json + { + "status": "ok", + "message": "Email successfully sent", + "info": { "from": "...", "to": "..." } + } + ``` + +18. **Inline MJML compilation in test route** — the test email body is compiled inline via `mjml2html()`, not via the template folder mechanism. + +19. **JSON Schema validation on test route** — 200 and 500 responses are validated against registered JSON schemas. + +20. **OpenAPI tagging on test route** — tagged `["email"]` with summary `"Test email"` for Swagger/OpenAPI tools. + +## TypeScript Integration + +21. **`FastifyInstance` module augmentation** — importing the plugin adds `mailer: FastifyMailer` to Fastify's instance type automatically. + +22. **`ApiConfig` module augmentation** — importing the plugin extends `@prefabs.tech/fastify-config`'s `ApiConfig` with `mailer: MailerConfig`. + +23. **Exported types** — `FastifyMailer`, `FastifyMailerNamedInstance`, and `MailerConfig` are exported for use in application code. diff --git a/packages/mailer/GUIDE.md b/packages/mailer/GUIDE.md new file mode 100644 index 000000000..f7c96760f --- /dev/null +++ b/packages/mailer/GUIDE.md @@ -0,0 +1,592 @@ +# @prefabs.tech/fastify-mailer — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-mailer nodemailer mjml fastify fastify-plugin + +# pnpm +pnpm add @prefabs.tech/fastify-mailer nodemailer mjml fastify fastify-plugin +``` + +Peer dependencies that must be installed alongside the package: + +| Peer dependency | Required version | +| ------------------------------ | -------------------------------------------------------- | +| `fastify` | `>=5.2.1` | +| `fastify-plugin` | `>=5.0.1` | +| `mjml` | `>=4.15.3` | +| `@prefabs.tech/fastify-config` | `0.93.5` (optional — only needed for legacy config mode) | + +### For monorepo development (pnpm install / test / build) + +```bash +# From repo root — installs all workspace dependencies +pnpm install + +# Run tests for this package only +pnpm --filter @prefabs.tech/fastify-mailer test + +# Build +pnpm --filter @prefabs.tech/fastify-mailer build + +# Type-check +pnpm --filter @prefabs.tech/fastify-mailer typecheck +``` + +## Setup + +Complete working example. All later examples in this guide assume this setup is in place. + +```typescript +import Fastify from "fastify"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; +// Importing the package automatically augments FastifyInstance and ApiConfig types. +import "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(mailerPlugin, { + transport: { + host: "smtp.example.com", + port: 587, + secure: false, + requireTLS: true, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }, + defaults: { + from: { + address: "noreply@myapp.com", + name: "My App", + }, + }, + templating: { + templateFolder: "./src/email-templates", + }, + // Optional: global template variables injected into every email + templateData: { + appName: "My App", + supportEmail: "support@myapp.com", + }, + // Optional: redirect all emails during development/staging + // recipients: ["dev@myapp.com"], + + // Optional: enable a test route at startup to verify mail delivery + // test: { enabled: true, path: "/test/email", to: "dev@myapp.com" }, +}); + +await fastify.ready(); +``` + +--- + +## Base Libraries + +### nodemailer — Partial Passthrough + +Their docs: https://www.npmjs.com/package/nodemailer + +`nodemailer.createTransport()` is called internally with the `transport` and `defaults` options you provide. `fastify.mailer` is built from that transporter, but `sendMail` is wrapped by this package before delivery. + +What we add on top: + +- We wrap `sendMail` to inject template data (global + per-email merge) before forwarding to the underlying transporter. +- When `recipients` is configured, we intercept the `to`, `cc`, and `bcc` fields before the call reaches nodemailer. +- `createTransport` and the raw `Transporter` are never directly exposed — access is always through `fastify.mailer`. + +### nodemailer-mjml — Partial Passthrough + +Their docs: https://www.npmjs.com/package/nodemailer-mjml + +The plugin is registered on nodemailer's `"compile"` lifecycle hook with the `templateFolder` you provide. We currently forward only `templateFolder` to `nodemailerMjmlPlugin`, even though `templating` is typed as `IPluginOptions`. + +### nodemailer-html-to-text — Modified + +Their docs: https://www.npmjs.com/package/nodemailer-html-to-text + +Registered on nodemailer's `"compile"` lifecycle hook after MJML (so it operates on already-compiled HTML). We always call `htmlToText()` with no options and do not expose configuration. + +### mjml — Modified + +Their docs: https://www.npmjs.com/package/mjml + +Used only inside the built-in test route to compile a hardcoded MJML snippet inline via `mjml2html()`. Application code that uses template files through nodemailer-mjml does not interact with this dependency directly. + +### fastify-plugin — Full Passthrough + +Their docs: https://www.npmjs.com/package/fastify-plugin + +The entire plugin is wrapped with `FastifyPlugin()` to opt out of Fastify's encapsulation scope. This means the `fastify.mailer` decorator is available in all child plugins and routes without re-registering. + +--- + +## Features + +### 1. Plugin registration info log + +When the plugin initialises it writes an `info`-level log entry: + +``` +Registering fastify-mailer plugin +``` + +No configuration required. + +### 2. Duplicate registration guard + +Registering the plugin twice on the same Fastify instance throws synchronously: + +```typescript +await fastify.register(mailerPlugin, options); // OK +await fastify.register(mailerPlugin, options); // throws "fastify-mailer has already been registered" +``` + +### 3. Config fallback (legacy mode) + +If you call `register()` with no options, the plugin looks for `fastify.config.mailer`. This requires the `@prefabs.tech/fastify-config` package to be registered first and is deprecated in favour of passing options directly. + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; + +// fastify-config populates fastify.config.mailer from environment / config file +await fastify.register(configPlugin); + +// mailerPlugin reads fastify.config.mailer automatically +await fastify.register(mailerPlugin); +``` + +When this path is taken the plugin logs a warning: + +``` +The mailer plugin now recommends passing mailer options directly to the plugin. +``` + +### 4. Missing config error + +When neither inline options nor `fastify.config.mailer` are present: + +```typescript +await fastify.register(mailerPlugin); +// Error: Missing mailer configuration. Did you forget to pass it to the mailer plugin? +``` + +### 5. Fastify encapsulation bypass + +Because the plugin is wrapped with `fastify-plugin`, the `fastify.mailer` decorator is visible to the entire server — including sibling plugins and parent scopes — without additional registration. + +```typescript +await fastify.register(mailerPlugin, options); + +fastify.register(async (childPlugin) => { + // fastify.mailer is accessible here without re-registering + await childPlugin.mailer.sendMail({ + to: "user@example.com", + subject: "Hi", + html: "

Hello

", + }); +}); +``` + +### 6. SMTP transport creation + +The `transport` option is passed verbatim to `nodemailer.createTransport()` alongside `defaults`. Any SMTP provider supported by nodemailer works. + +```typescript +await fastify.register(mailerPlugin, { + transport: { + host: "email-smtp.us-east-1.amazonaws.com", + port: 465, + secure: true, + auth: { user: process.env.SES_USER, pass: process.env.SES_PASS }, + }, + defaults: { from: { address: "no-reply@myapp.com", name: "My App" } }, + templating: { templateFolder: "./templates" }, +}); +``` + +### 7. Default sender via `defaults.from` + +`defaults.from` must contain an `address` and a `name`. These are used as the `From` header on every outgoing email. Any additional nodemailer `Options` (such as `replyTo`) can also live under `defaults`. + +```typescript +await fastify.register(mailerPlugin, { + // ... + defaults: { + from: { + address: "noreply@myapp.com", + name: "My App", + }, + replyTo: "support@myapp.com", + }, + // ... +}); +``` + +### 8. MJML compile hook + +On plugin startup, `nodemailerMjmlPlugin({ templateFolder })` is registered on nodemailer's `"compile"` lifecycle. Name your templates `.mjml` inside `templateFolder`. Reference them by name in `sendMail` calls via the `templateName` field (a nodemailer-mjml convention). + +```typescript +// Template at: ./src/email-templates/welcome.mjml +await fastify.mailer.sendMail({ + to: "user@example.com", + subject: "Welcome", + templateName: "welcome", // nodemailer-mjml resolves this to the .mjml file + templateData: { firstName: "Ada" }, // variables injected into the template +}); +``` + +See the [nodemailer-mjml docs](https://www.npmjs.com/package/nodemailer-mjml) for the full template syntax. + +### 9. Auto HTML-to-text conversion + +A plain-text `text` part is automatically generated from the `html` content of every email. No configuration is required and nothing needs to be set in `sendMail` calls. The conversion runs after MJML compilation. + +### 10. Transporter-backed `fastify.mailer` decorator + +`fastify.mailer` is created from the nodemailer transporter and adds a wrapped `sendMail` implementation that injects plugin behavior (templateData merge + optional recipient override): + +```typescript +await fastify.mailer.sendMail({ + to: "user@example.com", + subject: "Hello", + html: "

Hello

", +}); +``` + +### 11. Promise-based `sendMail` + +```typescript +const info = await fastify.mailer.sendMail({ + to: "user@example.com", + subject: "Order confirmation", + html: "

Your order has shipped.

", +}); +console.log("Message ID:", info.messageId); +``` + +### 12. Callback-based `sendMail` + +```typescript +fastify.mailer.sendMail( + { to: "user@example.com", subject: "Hi", html: "

Hello

" }, + (err, info) => { + if (err) return console.error(err); + console.log("Sent:", info.response); + }, +); +``` + +### 13. Global template data + +Set `templateData` at registration time to inject variables into every template without repeating them on every `sendMail` call. + +```typescript +await fastify.register(mailerPlugin, { + // ... + templateData: { + appName: "My App", + year: new Date().getFullYear(), + supportEmail: "support@myapp.com", + }, +}); +``` + +### 14. Per-email template data + +Pass `templateData` on individual `sendMail` calls for data specific to that email. + +```typescript +await fastify.mailer.sendMail({ + to: "user@example.com", + subject: "Your order", + templateName: "order-confirmation", + templateData: { + orderId: "ORD-001", + total: "$49.99", + }, +}); +``` + +### 15. Template data merge with override precedence + +Global `templateData` and per-email `templateData` are shallow-merged. Per-email values win on key conflicts. The global object is never mutated between calls. + +```typescript +// Registration: templateData = { appName: "My App", env: "production" } +// sendMail call: templateData = { env: "staging", orderId: "ORD-1" } +// Effective templateData passed to the template: +// { appName: "My App", env: "staging", orderId: "ORD-1" } +``` + +### 16. Redirect all emails to fixed addresses + +Set `recipients` to a non-empty array to force all outgoing emails to those addresses. The original `to`, `cc`, and `bcc` fields are overwritten or cleared. Useful for staging environments to prevent sending to real users. + +```typescript +await fastify.register(mailerPlugin, { + // ... + recipients: ["qa@myapp.com", "staging-monitor@myapp.com"], +}); + +// Even though `to` is a real user address, email goes only to recipients above. +await fastify.mailer.sendMail({ + to: "real-customer@example.com", + cc: "manager@example.com", + subject: "Order shipped", + html: "

Your order is on the way.

", +}); +// Delivered to: qa@myapp.com, staging-monitor@myapp.com +// cc and bcc: undefined +``` + +When `recipients` is an empty array or omitted, the original `to`, `cc`, and `bcc` values pass through unchanged. + +### 17. Conditional HTTP test route + +Enable a `GET` endpoint that sends a live test email and returns a JSON confirmation. Useful for smoke-testing SMTP connectivity in deployed environments. + +```typescript +await fastify.register(mailerPlugin, { + // ... + test: { + enabled: true, // set to false or omit `test` entirely to disable + path: "/internal/test-email", + to: "ops-team@myapp.com", + }, +}); + +// GET /internal/test-email +// Response: +// { "status": "ok", "message": "Email successfully sent", "info": { "from": "...", "to": "..." } } +``` + +### 18. Inline MJML compilation in test route + +The test route builds and compiles its email body inline using `mjml2html()`. It does not depend on the `templateFolder` configured for the application — no template files need to exist for the test route to work. + +### 19. JSON Schema validation on test route + +The test route declares response schemas for both 200 (success) and 500 (error) status codes. These are enforced by Fastify's built-in validation and serialisation. + +| Status | Required fields | +| ------ | ------------------------------- | +| 200 | `status`, `message`, `info` | +| 500 | `message`, `name`, `statusCode` | + +### 20. OpenAPI tagging on test route + +The test route is tagged `["email"]` with summary `"Test email"`. If you use `@fastify/swagger` or a compatible plugin, the route appears in the generated spec automatically. + +### 21. `FastifyInstance` module augmentation + +Importing `@prefabs.tech/fastify-mailer` extends Fastify's `FastifyInstance` interface with `mailer: FastifyMailer`. This gives full TypeScript type-checking on `fastify.mailer` and its methods. + +```typescript +// The augmentation happens automatically on import — no extra steps needed. +import "@prefabs.tech/fastify-mailer"; + +// fastify.mailer is now typed as FastifyMailer everywhere +const info = await fastify.mailer.sendMail({ ... }); +``` + +### 22. `ApiConfig` module augmentation + +Importing the plugin also extends `@prefabs.tech/fastify-config`'s `ApiConfig` interface with `mailer: MailerConfig`. This makes `fastify.config.mailer` fully typed when using the config plugin. + +```typescript +import "@prefabs.tech/fastify-mailer"; +// fastify.config.mailer is now typed as MailerConfig +``` + +### 23. Exported types + +Three TypeScript types are exported for use in application code: + +```typescript +import type { + FastifyMailer, + FastifyMailerNamedInstance, + MailerConfig, +} from "@prefabs.tech/fastify-mailer"; + +// Type a function that accepts the mailer +function scheduleEmail(mailer: FastifyMailer, to: string): Promise { + return mailer.sendMail({ to, subject: "Scheduled", html: "

Hi

" }); +} + +// Type your config object before passing to register() +const mailerConfig: MailerConfig = { + transport: { host: "smtp.example.com", port: 587 }, + defaults: { from: { address: "noreply@myapp.com", name: "My App" } }, + templating: { templateFolder: "./templates" }, +}; +``` + +--- + +## Use Cases + +### Use Case 1: Transactional email with an MJML template + +Send a styled HTML email using a `.mjml` file stored in the template folder. Global template data (year, brand name) is set once at registration; per-call data (recipient name, order ID) is provided when sending. + +```typescript +// ./src/email-templates/order-confirmation.mjml +// +// +// +// Hello {{firstName}}, your order {{orderId}} has been placed. +// © {{year}} {{appName}} +// +// +// + +import Fastify from "fastify"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(mailerPlugin, { + transport: { + host: "smtp.mailgun.org", + port: 587, + auth: { user: process.env.MG_USER!, pass: process.env.MG_PASS! }, + }, + defaults: { from: { address: "orders@myapp.com", name: "My App Orders" } }, + templating: { templateFolder: "./src/email-templates" }, + templateData: { + appName: "My App", + year: new Date().getFullYear(), + }, +}); + +await fastify.ready(); + +// In a route handler: +fastify.post("/orders", async (request, reply) => { + const { customerEmail, firstName, orderId } = request.body as { + customerEmail: string; + firstName: string; + orderId: string; + }; + + await fastify.mailer.sendMail({ + to: customerEmail, + subject: `Order ${orderId} confirmed`, + templateName: "order-confirmation", + templateData: { firstName, orderId }, + }); + + reply.send({ ok: true }); +}); +``` + +### Use Case 2: Staging environment recipient redirect + +Redirect all emails to an internal team inbox during staging so real users are never contacted. The `recipients` array completely replaces `to`, `cc`, and `bcc` on every outgoing email. + +```typescript +import Fastify from "fastify"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); +const isStaging = process.env.NODE_ENV === "staging"; + +await fastify.register(mailerPlugin, { + transport: { + host: process.env.SMTP_HOST!, + port: 587, + auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! }, + }, + defaults: { from: { address: "noreply@myapp.com", name: "My App" } }, + templating: { templateFolder: "./src/email-templates" }, + // Redirect all mail when on staging + ...(isStaging && { recipients: ["staging-inbox@myapp.com"] }), +}); +``` + +### Use Case 3: SMTP connectivity smoke test + +Enable the built-in test route to confirm that the SMTP connection is working after a deployment. Hit the endpoint once and check the response. + +```typescript +import Fastify from "fastify"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(mailerPlugin, { + transport: { + host: process.env.SMTP_HOST!, + port: 587, + auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! }, + }, + defaults: { from: { address: "noreply@myapp.com", name: "My App" } }, + templating: { templateFolder: "./src/email-templates" }, + test: { + enabled: process.env.ENABLE_MAIL_TEST_ROUTE === "true", + path: "/internal/smoke/email", + to: process.env.SMOKE_TEST_EMAIL ?? "ops@myapp.com", + }, +}); + +await fastify.ready(); +await fastify.listen({ port: 3000 }); + +// After startup: +// curl http://localhost:3000/internal/smoke/email +// → { "status": "ok", "message": "Email successfully sent", "info": { ... } } +``` + +### Use Case 4: Legacy config-driven setup via `@prefabs.tech/fastify-config` + +If your application already uses `@prefabs.tech/fastify-config` to manage all configuration from environment variables or a config file, you can register `mailerPlugin` without arguments and let it read from `fastify.config.mailer`. + +```typescript +import Fastify from "fastify"; +import configPlugin from "@prefabs.tech/fastify-config"; +import mailerPlugin from "@prefabs.tech/fastify-mailer"; +// Augments ApiConfig with mailer: MailerConfig +import "@prefabs.tech/fastify-mailer"; + +const fastify = Fastify({ logger: true }); + +// configPlugin reads env vars / config files and populates fastify.config +await fastify.register(configPlugin); + +// mailerPlugin finds its config at fastify.config.mailer automatically +await fastify.register(mailerPlugin); + +await fastify.ready(); +``` + +Note: this mode logs a deprecation warning. The recommended approach is to pass `MailerConfig` directly to `register()`. + +### Use Case 5: Sending email with a callback + +Use the Node.js-style callback API when integrating with legacy code that does not use Promises. + +```typescript +fastify.mailer.sendMail( + { + to: "user@example.com", + subject: "Account created", + html: "

Welcome aboard!

", + }, + (err, info) => { + if (err) { + fastify.log.error({ err }, "Failed to send welcome email"); + return; + } + fastify.log.info({ messageId: info.messageId }, "Welcome email sent"); + }, +); +``` diff --git a/packages/mailer/README.md b/packages/mailer/README.md index e4f5d3af4..f3d97f0e7 100644 --- a/packages/mailer/README.md +++ b/packages/mailer/README.md @@ -2,6 +2,18 @@ A [Fastify](https://github.com/fastify/fastify) plugin that when registered on a Fastify instance, will decorate it with a `mailer` object for email. +## Why this plugin? + +Sending production-ready emails is significantly more complex than just piping strings into NodeMailer. You must compile responsive HTML, define generic fallback text, inject dynamic payload data, and manage transport credentials securely. We created this plugin to: + +- **Unify the Mailing Pipeline**: It bundles `nodemailer`, `mustache` (for variable templating), `nodemailer-mjml` (for converting elegant MJML components to cross-client compatible HTML), and `html-to-text` into a single, cohesive processing pipeline. +- **Provide a Centralized Decorator**: By decorating the Fastify instance with a structured `mailer` object, dispatching rich emails from anywhere within your application is simplified to a single, type-safe API call. + +### Design Decisions: Why wrap Nodemailer instead of using external Saas SDKs? + +1. **Vendor Agnosticism**: Directly integrating SDKs like SendGrid or Postmark locks your application architecture. By wrapping NodeMailer natively, you can instantly pivot between different SMTP servers (e.g., AWS SES, Mailgun, or standard SMTP) just by updating `config/mailer.ts` without touching any business logic. +2. **Template Independence**: We chose MJML and Mustache for templates to keep your email designs purely structural and inside your repository. This eliminates the dependency on third-party drag-and-drop editors and ensures your email templates are rigorously version-controlled alongside your application logic. + ## Requirements - [html-to-text](https://github.com/html-to-text/node-html-to-text) @@ -15,13 +27,13 @@ A [Fastify](https://github.com/fastify/fastify) plugin that when registered on a Install with npm: ```bash -npm install @prefabs.tech/fastify-mailer html-to-text mustache nodemailer nodemailer nodemailer-html-to-text nodemailer-mjml +npm install @prefabs.tech/fastify-mailer html-to-text mustache nodemailer nodemailer-html-to-text nodemailer-mjml ``` Install with pnpm: ```bash -pnpm add --filter "@scope/project" @prefabs.tech/fastify-mailer html-to-text mustache nodemailer nodemailer nodemailer-html-to-text nodemailer-mjml +pnpm add --filter "@scope/project" @prefabs.tech/fastify-mailer html-to-text mustache nodemailer nodemailer-html-to-text nodemailer-mjml ``` ## Usage @@ -41,13 +53,13 @@ const start = async () => { const fastify = Fastify({ logger: config.logger, }); - + // Register mailer plugin await fastify.register(mailerPlugin, config.mailer); - + await fastify.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); }; @@ -55,6 +67,7 @@ start(); ``` ## Configuration + To configure the mailer, add the following settings to your `config/mailer.ts` file: ```typescript diff --git a/packages/mailer/package.json b/packages/mailer/package.json index 55e505741..e4bdc6ebc 100644 --- a/packages/mailer/package.json +++ b/packages/mailer/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-mailer", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify mailer plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/mailer#readme", "repository": { @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-mailer.cjs", "module": "./dist/prefabs-tech-fastify-mailer.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -36,26 +38,26 @@ "nodemailer-mjml": "1.6.0" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/tsconfig": "0.5.0", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/tsconfig": "0.7.0", "@types/mjml": "4.7.4", - "@types/node": "24.10.13", - "@types/nodemailer": "6.4.22", + "@types/node": "24.10.15", + "@types/nodemailer": "6.4.23", "@types/nodemailer-html-to-text": "3.1.3", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", "mjml": "4.18.0", - "prettier": "3.8.1", + "prettier": "3.8.3", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.5", - "fastify": ">=5.2.1", + "@prefabs.tech/fastify-config": "0.94.0", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1", "mjml": ">=4.15.3" }, @@ -67,4 +69,4 @@ "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/mailer/src/__test__/helpers/createMailerConfig.ts b/packages/mailer/src/__test__/helpers/createMailerConfig.ts index 6cf0362dc..8d82067b1 100644 --- a/packages/mailer/src/__test__/helpers/createMailerConfig.ts +++ b/packages/mailer/src/__test__/helpers/createMailerConfig.ts @@ -6,9 +6,9 @@ const createMailerConfig = () => { name: "Mailer Team", }, }, - test: { enabled: true, path: "/test/email", to: "receiver@example.com" }, - templating: { templateFolder: "mjml/templates" }, templateData: { exampleUrl: "http://localhost:2000/" }, + templating: { templateFolder: "mjml/templates" }, + test: { enabled: true, path: "/test/email", to: "receiver@example.com" }, transport: { auth: { pass: "pass", user: "user" }, host: "localhost", diff --git a/packages/mailer/src/__test__/mailer.spec.ts b/packages/mailer/src/__test__/mailer.spec.ts deleted file mode 100644 index 29ff0fe1d..000000000 --- a/packages/mailer/src/__test__/mailer.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import fastify from "fastify"; -import { describe, expect, it, vi, beforeEach } from "vitest"; - -import createMailerConfig from "./helpers/createMailerConfig"; - -import type { FastifyInstance } from "fastify"; - -const nodemailerMjmlPluginMock = vi.fn(); -const htmlToTextMock = vi.fn(); -const useMock = vi.fn(); -const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); -const createTransportMock = vi.fn().mockReturnValue({ - sendMail: sendMailMock, - use: useMock, -}); - -vi.mock("nodemailer", () => ({ - createTransport: createTransportMock, -})); - -vi.mock("nodemailer-mjml", () => ({ - nodemailerMjmlPlugin: nodemailerMjmlPluginMock, -})); - -vi.mock("nodemailer-html-to-text", () => ({ - htmlToText: htmlToTextMock, -})); - -describe("Mailer", async () => { - let api: FastifyInstance; - - const { default: plugin } = await import("../plugin"); - - beforeEach(async () => { - api = await fastify(); - - api.decorate("config", { mailer: createMailerConfig() }); - }); - - it("Create Mailer instance with ", async () => { - const { transport, defaults, templating } = createMailerConfig(); - await api.register(plugin, createMailerConfig()); - - expect(createTransportMock).toHaveBeenCalledWith(transport, defaults); - - expect(useMock).toHaveBeenCalledWith("compile", nodemailerMjmlPluginMock()); - - expect(useMock).toHaveBeenCalledWith("compile", htmlToTextMock()); - - expect(nodemailerMjmlPluginMock).toHaveBeenCalledWith({ - templateFolder: templating.templateFolder, - }); - }); - - it("Should throw error if mailer already registered to api", async () => { - await api.register(plugin, createMailerConfig()); - - await expect( - api.register(plugin, createMailerConfig()), - ).rejects.toThrowError("fastify-mailer has already been registered"); - }); - - it("Should call SendMail method ", async () => { - const { - templateData, - test: { path, to }, - } = createMailerConfig(); - - await api.register(plugin, createMailerConfig()); - - await api.inject({ - method: "GET", - path: path, - }); - - expect(sendMailMock).toHaveBeenCalledWith({ - html: expect.stringContaining(""), - subject: "Test email", - to: to, - templateData: templateData, - }); - }); -}); diff --git a/packages/mailer/src/__test__/recipients.test.ts b/packages/mailer/src/__test__/recipients.test.ts new file mode 100644 index 000000000..1dc2d38c0 --- /dev/null +++ b/packages/mailer/src/__test__/recipients.test.ts @@ -0,0 +1,151 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createMailerConfig from "./helpers/createMailerConfig"; + +const { createTransportMock, sendMailMock } = vi.hoisted(() => { + const useMock = vi.fn(); + const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); + const createTransportMock = vi.fn().mockReturnValue({ + sendMail: sendMailMock, + use: useMock, + }); + return { createTransportMock, sendMailMock }; +}); + +vi.mock("nodemailer", () => ({ + createTransport: createTransportMock, +})); + +vi.mock("nodemailer-mjml", () => ({ + nodemailerMjmlPlugin: vi.fn(), +})); + +vi.mock("nodemailer-html-to-text", () => ({ + htmlToText: vi.fn(), +})); + +const baseMailOptions = { + bcc: "bcc@example.com", + cc: "cc@example.com", + html: "

Hi

", + subject: "Test", + to: "user@example.com", +}; + +describe("mailerPlugin — recipient override", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when recipients is not configured", () => { + beforeEach(async () => { + fastify = Fastify({ logger: false }); + const config = createMailerConfig(); + delete (config as { recipients?: unknown }).recipients; + await fastify.register(plugin, config); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("passes the original to through unchanged", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toBe("user@example.com"); + }); + + it("passes the original cc through unchanged", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.cc).toBe("cc@example.com"); + }); + + it("passes the original bcc through unchanged", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.bcc).toBe("bcc@example.com"); + }); + }); + + describe("when recipients is an empty array", () => { + beforeEach(async () => { + fastify = Fastify({ logger: false }); + await fastify.register(plugin, { + ...createMailerConfig(), + recipients: [], + }); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("does not override to", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toBe("user@example.com"); + }); + + it("does not clear cc", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.cc).toBe("cc@example.com"); + }); + }); + + describe("when recipients array is configured", () => { + const recipients = ["qa@myapp.com", "staging@myapp.com"]; + + beforeEach(async () => { + fastify = Fastify({ logger: false }); + await fastify.register(plugin, { + ...createMailerConfig(), + recipients, + }); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("overrides to with the recipients array", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toEqual(recipients); + }); + + it("sets cc to undefined", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.cc).toBeUndefined(); + }); + + it("sets bcc to undefined", async () => { + await fastify.mailer.sendMail(baseMailOptions); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.bcc).toBeUndefined(); + }); + + it("still delivers to recipients even when to was different", async () => { + await fastify.mailer.sendMail({ + ...baseMailOptions, + to: "real-customer@example.com", + }); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toEqual(recipients); + expect(calledWith.to).not.toContain("real-customer@example.com"); + }); + }); +}); diff --git a/packages/mailer/src/__test__/registration.test.ts b/packages/mailer/src/__test__/registration.test.ts new file mode 100644 index 000000000..b067dc920 --- /dev/null +++ b/packages/mailer/src/__test__/registration.test.ts @@ -0,0 +1,169 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createMailerConfig from "./helpers/createMailerConfig"; + +const { createTransportMock, useMock } = vi.hoisted(() => { + const useMock = vi.fn(); + const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); + const createTransportMock = vi.fn().mockReturnValue({ + sendMail: sendMailMock, + use: useMock, + }); + return { createTransportMock, useMock }; +}); + +vi.mock("nodemailer", () => ({ + createTransport: createTransportMock, +})); + +vi.mock("nodemailer-mjml", () => ({ + nodemailerMjmlPlugin: vi.fn(), +})); + +vi.mock("nodemailer-html-to-text", () => ({ + htmlToText: vi.fn(), +})); + +describe("mailerPlugin — registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("registers without throwing", async () => { + await expect( + fastify.register(plugin, createMailerConfig()), + ).resolves.not.toThrow(); + }); + + it("decorates fastify with mailer after registration", async () => { + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + expect(fastify.mailer).toBeDefined(); + }); + + it("fastify.mailer exposes sendMail", async () => { + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + expect(typeof fastify.mailer.sendMail).toBe("function"); + }); + + it("calls createTransport with transport and defaults", async () => { + const { defaults, transport } = createMailerConfig(); + await fastify.register(plugin, createMailerConfig()); + expect(createTransportMock).toHaveBeenCalledWith(transport, defaults); + }); + + it("registers MJML compile hook with configured templateFolder", async () => { + const { nodemailerMjmlPlugin } = await import("nodemailer-mjml"); + const { templating } = createMailerConfig(); + await fastify.register(plugin, createMailerConfig()); + expect(nodemailerMjmlPlugin).toHaveBeenCalledWith({ + templateFolder: templating.templateFolder, + }); + expect(useMock).toHaveBeenCalledWith( + "compile", + (nodemailerMjmlPlugin as ReturnType)(), + ); + }); + + it("registers html-to-text compile hook", async () => { + const { htmlToText } = await import("nodemailer-html-to-text"); + await fastify.register(plugin, createMailerConfig()); + expect(useMock).toHaveBeenCalledWith( + "compile", + (htmlToText as ReturnType)(), + ); + }); + + it("throws when registered twice on the same instance", async () => { + await fastify.register(plugin, createMailerConfig()); + await expect( + fastify.register(plugin, createMailerConfig()), + ).rejects.toThrow("fastify-mailer has already been registered"); + }); + + it("logs an info message when the plugin starts registering", async () => { + await fastify.close(); + fastify = Fastify({ logger: { level: "silent" } }); + const infoSpy = vi.spyOn(fastify.log, "info"); + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + expect(infoSpy).toHaveBeenCalledWith("Registering fastify-mailer plugin"); + }); + + it("exposes fastify.mailer inside nested plugin registrations without re-registering", async () => { + await fastify.register(plugin, createMailerConfig()); + let nestedHasMailer = false; + await fastify.register(async (instance) => { + nestedHasMailer = typeof instance.mailer?.sendMail === "function"; + }); + await fastify.ready(); + expect(nestedHasMailer).toBe(true); + }); +}); + +describe("mailerPlugin — legacy config fallback", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("reads options from fastify.config.mailer when no options passed to register()", async () => { + const config = createMailerConfig(); + fastify.decorate("config", { mailer: config }); + + await fastify.register(plugin); + await fastify.ready(); + + expect(createTransportMock).toHaveBeenCalledWith( + config.transport, + config.defaults, + ); + }); + + it("fastify.mailer is available after legacy registration", async () => { + fastify.decorate("config", { mailer: createMailerConfig() }); + await fastify.register(plugin); + await fastify.ready(); + expect(fastify.mailer).toBeDefined(); + }); + + it("throws a descriptive error when no options and no fastify.config.mailer", async () => { + await expect(fastify.register(plugin)).rejects.toThrow( + "Missing mailer configuration. Did you forget to pass it to the mailer plugin?", + ); + }); + + it("warns when falling back to fastify.config.mailer", async () => { + await fastify.close(); + fastify = Fastify({ logger: { level: "silent" } }); + const warnSpy = vi.spyOn(fastify.log, "warn"); + fastify.decorate("config", { mailer: createMailerConfig() }); + await fastify.register(plugin); + await fastify.ready(); + expect(warnSpy).toHaveBeenCalledWith( + "The mailer plugin now recommends passing mailer options directly to the plugin.", + ); + }); +}); diff --git a/packages/mailer/src/__test__/schema.test.ts b/packages/mailer/src/__test__/schema.test.ts new file mode 100644 index 000000000..fcdfbf973 --- /dev/null +++ b/packages/mailer/src/__test__/schema.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { testEmailSchema } from "../schema"; + +describe("testEmailSchema", () => { + it("tags the test email route for OpenAPI tools", () => { + expect(testEmailSchema.tags).toEqual(["email"]); + }); + + it("documents a summary for the test email route", () => { + expect(testEmailSchema.summary).toBe("Test email"); + }); + + it("requires status, message, and info on successful responses", () => { + const ok = testEmailSchema.response[200]; + expect(ok.required).toEqual(["status", "message", "info"]); + }); +}); diff --git a/packages/mailer/src/__test__/sendMail.test.ts b/packages/mailer/src/__test__/sendMail.test.ts new file mode 100644 index 000000000..380abd1cd --- /dev/null +++ b/packages/mailer/src/__test__/sendMail.test.ts @@ -0,0 +1,214 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createMailerConfig from "./helpers/createMailerConfig"; + +const { createTransportMock, sendMailMock } = vi.hoisted(() => { + const useMock = vi.fn(); + const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); + const createTransportMock = vi.fn().mockReturnValue({ + sendMail: sendMailMock, + use: useMock, + }); + return { createTransportMock, sendMailMock }; +}); + +vi.mock("nodemailer", () => ({ + createTransport: createTransportMock, +})); + +vi.mock("nodemailer-mjml", () => ({ + nodemailerMjmlPlugin: vi.fn(), +})); + +vi.mock("nodemailer-html-to-text", () => ({ + htmlToText: vi.fn(), +})); + +describe("mailerPlugin — sendMail › template data", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("passes empty templateData when neither global nor per-email is set", async () => { + const config = createMailerConfig(); + delete (config as { templateData?: unknown }).templateData; + + await fastify.register(plugin, config); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + to: "user@example.com", + }); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ templateData: {} }), + ); + }); + + it("passes global templateData when no per-email templateData given", async () => { + const globalData = { appName: "MyApp", year: 2025 }; + await fastify.register(plugin, { + ...createMailerConfig(), + templateData: globalData, + }); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + to: "user@example.com", + }); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ templateData: globalData }), + ); + }); + + it("passes per-email templateData when no global templateData configured", async () => { + const config = createMailerConfig(); + delete (config as { templateData?: unknown }).templateData; + + await fastify.register(plugin, config); + await fastify.ready(); + + const perEmailData = { orderId: "ORD-001", total: "$9.99" }; + await fastify.mailer.sendMail({ + html: "

Confirmed

", + subject: "Order", + templateData: perEmailData, + to: "user@example.com", + }); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ templateData: perEmailData }), + ); + }); + + it("merges global and per-email templateData into a single object", async () => { + await fastify.register(plugin, { + ...createMailerConfig(), + templateData: { appName: "MyApp", supportEmail: "help@myapp.com" }, + }); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + templateData: { orderId: "ORD-123" }, + to: "user@example.com", + }); + + expect(sendMailMock).toHaveBeenCalledWith( + expect.objectContaining({ + templateData: { + appName: "MyApp", + orderId: "ORD-123", + supportEmail: "help@myapp.com", + }, + }), + ); + }); + + it("per-email templateData overrides global on key conflict", async () => { + await fastify.register(plugin, { + ...createMailerConfig(), + templateData: { appName: "MyApp", env: "production" }, + }); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + templateData: { env: "staging" }, + to: "user@example.com", + }); + + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.templateData.env).toBe("staging"); + expect(calledWith.templateData.appName).toBe("MyApp"); + }); + + it("resolves with the transporter sendMail result when using the promise API", async () => { + const sentInfo = { messageId: "", response: "250 OK" }; + sendMailMock.mockResolvedValueOnce(sentInfo); + + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + + const result = await fastify.mailer.sendMail({ + html: "

Hi

", + subject: "Test", + to: "user@example.com", + }); + + expect(result).toEqual(sentInfo); + }); + + it("global templateData is not mutated by per-email overrides", async () => { + const globalData = { env: "production" }; + await fastify.register(plugin, { + ...createMailerConfig(), + templateData: globalData, + }); + await fastify.ready(); + + await fastify.mailer.sendMail({ + html: "

1

", + subject: "1", + templateData: { env: "staging" }, + to: "a@example.com", + }); + + await fastify.mailer.sendMail({ + html: "

2

", + subject: "2", + to: "b@example.com", + }); + + const secondCall = sendMailMock.mock.calls[1][0]; + expect(secondCall.templateData.env).toBe("production"); + }); +}); + +describe("mailerPlugin — sendMail › callback", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + await fastify.register(plugin, createMailerConfig()); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("invokes callback on success", async () => { + const callback = vi.fn(); + + await fastify.mailer.sendMail( + { html: "

Hi

", subject: "Hi", to: "user@example.com" }, + callback, + ); + + expect(sendMailMock).toHaveBeenCalledWith(expect.any(Object), callback); + }); +}); diff --git a/packages/mailer/src/__test__/testRoute.test.ts b/packages/mailer/src/__test__/testRoute.test.ts new file mode 100644 index 000000000..aaa6aff87 --- /dev/null +++ b/packages/mailer/src/__test__/testRoute.test.ts @@ -0,0 +1,162 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../index"; +import createMailerConfig from "./helpers/createMailerConfig"; + +const { createTransportMock, sendMailMock } = vi.hoisted(() => { + const useMock = vi.fn(); + const sendMailMock = vi.fn().mockResolvedValue({ response: "250 OK" }); + const createTransportMock = vi.fn().mockReturnValue({ + sendMail: sendMailMock, + use: useMock, + }); + return { createTransportMock, sendMailMock }; +}); + +vi.mock("nodemailer", () => ({ + createTransport: createTransportMock, +})); + +vi.mock("nodemailer-mjml", () => ({ + nodemailerMjmlPlugin: vi.fn(), +})); + +vi.mock("nodemailer-html-to-text", () => ({ + htmlToText: vi.fn(), +})); + +describe("mailerPlugin — test route › conditional registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (fastify) { + await fastify.close(); + } + }); + + it("does not register a route when test option is omitted", async () => { + fastify = Fastify({ logger: false }); + const config = createMailerConfig(); + delete (config as { test?: unknown }).test; + + await fastify.register(plugin, config); + await fastify.ready(); + + const res = await fastify.inject({ method: "GET", url: "/test/email" }); + expect(res.statusCode).toBe(404); + }); + + it("does not register a route when test.enabled is false", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(plugin, { + ...createMailerConfig(), + test: { enabled: false, path: "/test/email", to: "dev@example.com" }, + }); + await fastify.ready(); + + const res = await fastify.inject({ method: "GET", url: "/test/email" }); + expect(res.statusCode).toBe(404); + }); + + it("registers a GET route at test.path when test.enabled is true", async () => { + fastify = Fastify({ logger: false }); + await fastify.register(plugin, { + ...createMailerConfig(), + test: { enabled: true, path: "/custom/test-mail", to: "dev@example.com" }, + }); + await fastify.ready(); + + const res = await fastify.inject({ + method: "GET", + url: "/custom/test-mail", + }); + expect(res.statusCode).not.toBe(404); + }); +}); + +describe("mailerPlugin — test route › HTTP response", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + const testConfig = createMailerConfig(); + + beforeEach(async () => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + await fastify.register(plugin, testConfig); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("GET test route returns status 200", async () => { + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.statusCode).toBe(200); + }); + + it("response body has status: ok", async () => { + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.json().status).toBe("ok"); + }); + + it("response body has message: Email successfully sent", async () => { + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.json().message).toBe("Email successfully sent"); + }); + + it("response body has an info object", async () => { + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.json().info).toBeDefined(); + expect(typeof res.json().info).toBe("object"); + }); + + it("sendMail is called with the configured test.to address", async () => { + await fastify.inject({ method: "GET", url: testConfig.test.path }); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.to).toBe(testConfig.test.to); + }); + + it("sendMail is called with subject Test email", async () => { + await fastify.inject({ method: "GET", url: testConfig.test.path }); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.subject).toBe("Test email"); + }); + + it("sendMail is called with compiled HTML content", async () => { + await fastify.inject({ method: "GET", url: testConfig.test.path }); + const calledWith = sendMailMock.mock.calls[0][0]; + expect(calledWith.html).toContain(""); + }); + + it("returns 500 when sendMail rejects", async () => { + sendMailMock.mockRejectedValueOnce(new Error("SMTP failure")); + const res = await fastify.inject({ + method: "GET", + url: testConfig.test.path, + }); + expect(res.statusCode).toBe(500); + }); +}); diff --git a/packages/mailer/src/index.ts b/packages/mailer/src/index.ts index d6cd42640..105f2c78a 100644 --- a/packages/mailer/src/index.ts +++ b/packages/mailer/src/index.ts @@ -1,7 +1,8 @@ -import type { FastifyMailer, MailerConfig } from "./types"; /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { FastifyMailer, MailerConfig } from "./types"; + declare module "fastify" { interface FastifyInstance { mailer: FastifyMailer; diff --git a/packages/mailer/src/plugin.ts b/packages/mailer/src/plugin.ts index e1f9b2b45..8d45a252f 100644 --- a/packages/mailer/src/plugin.ts +++ b/packages/mailer/src/plugin.ts @@ -1,14 +1,15 @@ +import type { FastifyInstance } from "fastify"; +import type { MailOptions } from "nodemailer/lib/sendmail-transport"; + import FastifyPlugin from "fastify-plugin"; import { createTransport } from "nodemailer"; -import SMTPTransport from "nodemailer/lib/smtp-transport"; import { htmlToText } from "nodemailer-html-to-text"; import { nodemailerMjmlPlugin } from "nodemailer-mjml"; - -import router from "./router"; +import SMTPTransport from "nodemailer/lib/smtp-transport"; import type { FastifyMailer, MailerOptions } from "./types"; -import type { FastifyInstance } from "fastify"; -import type { MailOptions } from "nodemailer/lib/sendmail-transport"; + +import router from "./router"; const plugin = async (fastify: FastifyInstance, options: MailerOptions) => { fastify.log.info("Registering fastify-mailer plugin"); @@ -18,7 +19,7 @@ const plugin = async (fastify: FastifyInstance, options: MailerOptions) => { "The mailer plugin now recommends passing mailer options directly to the plugin.", ); - if (!fastify.config.mailer) { + if (!fastify.config?.mailer) { throw new Error( "Missing mailer configuration. Did you forget to pass it to the mailer plugin?", ); @@ -29,11 +30,11 @@ const plugin = async (fastify: FastifyInstance, options: MailerOptions) => { const { defaults, + recipients, + templateData: configTemplateData, templating, test, transport, - templateData: configTemplateData, - recipients, } = options; const transporter = createTransport(transport, defaults); @@ -75,9 +76,9 @@ const plugin = async (fastify: FastifyInstance, options: MailerOptions) => { if (recipients && recipients.length > 0) { mailerOptions = { + ...mailerOptions, bcc: undefined, cc: undefined, - ...mailerOptions, to: recipients, }; } diff --git a/packages/mailer/src/router.ts b/packages/mailer/src/router.ts index a9b5af15d..c53fcce73 100644 --- a/packages/mailer/src/router.ts +++ b/packages/mailer/src/router.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + import mjml2html from "mjml"; import { testEmailSchema } from "./schema"; -import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; - const router = async ( fastify: FastifyInstance, options: { @@ -46,9 +46,9 @@ const router = async ( }); reply.send({ - status: "ok", - message: "Email successfully sent", info, + message: "Email successfully sent", + status: "ok", }); }, ); diff --git a/packages/mailer/src/schema.ts b/packages/mailer/src/schema.ts index e5fe080f5..551db9d99 100644 --- a/packages/mailer/src/schema.ts +++ b/packages/mailer/src/schema.ts @@ -1,41 +1,41 @@ const errorSchema = { - type: "object", + additionalProperties: true, properties: { code: { type: "string" }, error: { type: "string" }, message: { type: "string" }, name: { type: "string" }, stack: { - type: "array", items: { type: "object" }, + type: "array", }, statusCode: { type: "number" }, }, required: ["message", "name", "statusCode"], - additionalProperties: true, + type: "object", }; export const testEmailSchema = { response: { 200: { - type: "object", properties: { - status: { type: "string", const: "ok" }, - message: { type: "string", const: "Email successfully sent" }, info: { - type: "object", properties: { from: { type: "string" }, to: { type: "string" }, }, + type: "object", }, + message: { const: "Email successfully sent", type: "string" }, + status: { const: "ok", type: "string" }, }, required: ["status", "message", "info"], + type: "object", }, 500: { ...errorSchema, }, }, - tags: ["email"], summary: "Test email", + tags: ["email"], }; diff --git a/packages/mailer/src/types.ts b/packages/mailer/src/types.ts index b95b1f3d8..d8a7933a1 100644 --- a/packages/mailer/src/types.ts +++ b/packages/mailer/src/types.ts @@ -1,7 +1,13 @@ import type { Transporter } from "nodemailer"; +import type { IPluginOptions } from "nodemailer-mjml"; import type { Options } from "nodemailer/lib/mailer/"; import type { Options as SMTPOptions } from "nodemailer/lib/smtp-transport"; -import type { IPluginOptions } from "nodemailer-mjml"; + +type FastifyMailer = FastifyMailerNamedInstance & Transporter; + +interface FastifyMailerNamedInstance { + [namespace: string]: Transporter; +} interface MailerConfig { defaults: Partial & { @@ -14,8 +20,8 @@ interface MailerConfig { * Any email sent from the API will be directed to these addresses. */ recipients?: string[]; - templating: IPluginOptions; templateData?: Record; + templating: IPluginOptions; test?: { enabled: boolean; path: string; @@ -26,15 +32,9 @@ interface MailerConfig { type MailerOptions = MailerConfig; -interface FastifyMailerNamedInstance { - [namespace: string]: Transporter; -} - -type FastifyMailer = FastifyMailerNamedInstance & Transporter; - export type { - FastifyMailerNamedInstance, FastifyMailer, + FastifyMailerNamedInstance, MailerConfig, MailerOptions, }; diff --git a/packages/mailer/vite.config.ts b/packages/mailer/vite.config.ts index c006fcba5..caa5274ca 100644 --- a/packages/mailer/vite.config.ts +++ b/packages/mailer/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; diff --git a/packages/s3/FEATURES.md b/packages/s3/FEATURES.md new file mode 100644 index 000000000..dc272527d --- /dev/null +++ b/packages/s3/FEATURES.md @@ -0,0 +1,115 @@ + + +# Features: @prefabs.tech/fastify-s3 + +## Plugin Registration + +1. **Main plugin (`default` export)** — Fastify plugin wrapped with `fastify-plugin`. On registration it runs database migrations, conditionally registers `@fastify/multipart` (when `config.rest.enabled` is `true`), and conditionally registers GraphQL upload support (when `config.graphql?.enabled` is `true`). + +2. **Automatic database migration** — On registration the plugin creates (if not exists) the files table using the configured table name (`config.s3.table.name`) or the default name `"files"`. + +3. **Conditional REST multipart registration** — When `config.rest.enabled` is `true`, `@fastify/multipart` is registered with `attachFieldsToBody: "keyValues"`, a shared schema id of `"fileSchema"`, a file-size limit from `config.s3.fileSizeLimitInBytes` (defaults to `Infinity`), and an `onFile` hook that converts every part to a `{ data, encoding, filename, mimetype }` object and attaches it as the field value. + +4. **Conditional GraphQL upload registration** — When `config.graphql?.enabled` is `true`, the internal `graphqlUpload` plugin is registered, passing `maxFileSize` from `config.s3.fileSizeLimitInBytes` (defaults to `Infinity`). + +## Configuration + +5. **`S3Config` interface** — Defines the `s3` key required inside `ApiConfig`: + - `clientConfig: S3ClientConfig` — passed straight to the AWS SDK `S3Client` constructor. + - `bucket: string | Record` — default bucket or named-bucket map. + - `fileSizeLimitInBytes?: number` — optional global file-size cap applied to both REST and GraphQL upload paths. + - `filenameResolutionStrategy?: "overwrite" | "add-suffix" | "error"` — global default strategy when a key collision is detected in S3. + - `table?: { name?: string }` — overrides the default `"files"` table name. + +6. **Module augmentation of `@prefabs.tech/fastify-config`** — Adds `s3: S3Config` to the `ApiConfig` interface so the config is accessible via `fastify.config.s3` throughout the application. + +## Sub-plugins (independently exportable) + +7. **`ajvFilePlugin`** — AJV keyword plugin that registers the `isFile` custom keyword. Schemas using `isFile: true` validate that the value is a multipart file object (`{ data, filename, mimetype }`). For array schemas it validates every element. During compile the keyword also rewrites the parent schema (`type: "string"`, `format: "binary"`) so OpenAPI tooling renders a proper file-upload schema. + +8. **`multipartParserPlugin`** — Fastify plugin that registers a catch-all `"*"` content-type parser. For multipart requests it routes GraphQL-multipart requests (matching the configured GraphQL path) by setting `req.graphqlFileUploadMultipart = true`, while all other multipart requests are parsed immediately with Busboy into `{ fields..., files... }` and stored on `req.body`. Non-multipart content types fall through unchanged. Augments `FastifyRequest` with the optional `graphqlFileUploadMultipart?: boolean` property. + +## `S3Client` Utility Class + +9. **`S3Client` class** — Thin class wrapper around `@aws-sdk/client-s3`. Constructed with an `S3ClientConfig`. Exposes a mutable `bucket` property so a single instance can be reused across different buckets. + +10. **`S3Client.upload(fileStream, key, mimetype)`** — Uploads a `Buffer` or `ReadStream` to the configured bucket using `@aws-sdk/lib-storage` `Upload` (supports multipart uploads). Returns `AbortMultipartUploadCommandOutput | CompleteMultipartUploadCommandOutput`. + +11. **`S3Client.get(filePath)`** — Downloads an object and returns `{ Body: Buffer, ContentType: string | undefined }`. The response stream is consumed internally and converted to a `Buffer` via `convertStreamToBuffer`. + +12. **`S3Client.delete(filePath)`** — Sends a `DeleteObjectCommand` and returns `DeleteObjectCommandOutput`. + +13. **`S3Client.generatePresignedUrl(filePath, originalFileName, signedUrlExpiresInSecond?)`** — Generates a `GetObject` presigned URL that forces `Content-Disposition: attachment; filename=""`. Default expiry is `3600` seconds. + +14. **`S3Client.getObjects(baseName)`** — Lists all objects in the bucket whose key starts with the given prefix. Returns `ListObjectsCommandOutput`. + +15. **`S3Client.isFileExists(key)`** — Uses `HeadObjectCommand` to check existence. Returns `true` if the object exists, `false` on a `NotFound` error, and re-throws all other errors. + +## `FileService` (Database + S3 Coordinator) + +16. **`FileService` class** — Extends `BaseService` from `@prefabs.tech/fastify-slonik`. Coordinates S3 operations with database persistence using the `files` table (or the configured table name). + +17. **`FileService.upload(data: FilePayload)`** — Full upload pipeline: + - Determines the target bucket via `getPreferredBucket` (respects `bucketChoice: "optionsBucket" | "fileFieldsBucket"` or falls back to whichever bucket is set). + - Checks if the key already exists in S3 (`isFileExists`). + - Applies `filenameResolutionStrategy`: `"error"` throws `FILE_ALREADY_EXISTS_IN_S3_ERROR`; `"add-suffix"` lists existing objects with the same base name and appends the next numeric suffix (e.g. `report-2.pdf`); `"overwrite"` proceeds without modification. + - Falls back to a UUID-based filename when no name is provided. + - Persists the record to the database via `BaseService.create`. + +18. **`FileService.download(id, options?)`** — Looks up the file record by ID (throws `FILE_NOT_FOUND_ERROR` if missing), retrieves the S3 object, and returns the file record merged with `{ fileStream: Buffer, mimeType: string }`. + +19. **`FileService.deleteFile(fileId, options?)`** — Looks up the file record (throws `FILE_NOT_FOUND_ERROR` if missing), deletes the database record, then deletes the S3 object. + +20. **`FileService.presignedUrl(id, options: PresignedUrlOptions)`** — Looks up the file record (throws `FILE_NOT_FOUND_ERROR` if missing) and returns the record merged with `{ url: string }` — the presigned download URL. + +21. **`FileService.key` (computed property)** — Builds the S3 object key as `/`, normalising the trailing slash on `path`. + +22. **`FileService.filename` (computed property with UUID fallback)** — Returns the configured filename (adding the extension if missing), or a `uuid-v4.ext` name when no filename is set. + +## `FileSqlFactory` + +23. **`FileSqlFactory`** — Extends `DefaultSqlFactory` from `@prefabs.tech/fastify-slonik`. Overrides the `table` getter to return `config.s3.table.name` when set, falling back to the static default `"files"`. + +## Utility Functions + +24. **`convertStreamToBuffer(stream)`** — Internal utility used by `S3Client.get` to consume a `Readable` stream and resolve a single concatenated `Buffer` (not exported from the package root). + +25. **`getPreferredBucket(optionsBucket?, fileFieldsBucket?, bucketChoice?)`** — Determines which bucket to use. With explicit `bucketChoice` the named bucket wins; without it, `fileFieldsBucket` takes precedence over `optionsBucket` when both are present. + +26. **`getFilenameWithSuffix(listObjects, baseFilename, fileExtension)`** — Scans existing S3 object keys matching `-.`, finds the maximum `N`, and returns `-.`. + +27. **`getBaseName(filename)`** — Strips the last extension from a filename string. + +28. **`getFileExtension(filename)`** — Extracts the extension (without dot) from a filename string. Returns `""` for extensionless filenames. + +## Database Schema (Auto-migrated) + +29. **`createFilesTableQuery(config)`** — Returns a `CREATE TABLE IF NOT EXISTS` SQL query for the files table with columns: `id`, `original_file_name`, `bucket`, `description`, `key`, `uploaded_by_id`, `uploaded_at`, `download_count` (default `0`), `last_downloaded_at`, `created_at`, `updated_at`. Table name comes from `config.s3.table.name` or defaults to `"files"`. + +## Error Codes + +30. **`ERROR_CODES.FILE_NOT_FOUND`** (`"FILE_NOT_FOUND_ERROR"`) — Thrown by `FileService.download`, `presignedUrl`, and `deleteFile` when the requested file ID is not found in the database. + +31. **`ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3`** (`"FILE_ALREADY_EXISTS_IN_S3_ERROR"`) — Thrown by `FileService.upload` when a key collision is detected and `filenameResolutionStrategy` is `"error"`. + +## Type Exports + +32. **`S3Config`** — Plugin configuration shape (see Feature 5). + +33. **`FilePayload`** — Input type for `FileService.upload`, containing `{ file: { fileContent: Multipart, fileFields: FileCreateInput }, options?: FilePayloadOptions }`. + +34. **`FilePayloadOptions`** — Upload options: `bucket?`, `bucketChoice?`, `filenameResolutionStrategy?`, `path?`. + +35. **`Multipart`** — Normalised multipart file object: `{ data: Buffer | ReadStream, encoding?, filename, limit?, mimetype }`. + +36. **`FilenameResolutionStrategy`** — Union type `"overwrite" | "add-suffix" | "error"`. + +37. **`BucketChoice`** — Union type `"optionsBucket" | "fileFieldsBucket"`. + +38. **`File`** — Database model for a file record. + +39. **`FileCreateInput`** / **`FileUpdateInput`** — Input types for creating and updating file records. + +40. **`GraphQLFileUpload`** / **`GraphQLUpload`** — Re-exported from `graphql-upload-minimal` for consumers using GraphQL file uploads. + +41. **`S3ClientConfig`** — Re-exported from `@aws-sdk/client-s3` for consumers constructing raw S3 client configurations. diff --git a/packages/s3/GUIDE.md b/packages/s3/GUIDE.md new file mode 100644 index 000000000..876acacbc --- /dev/null +++ b/packages/s3/GUIDE.md @@ -0,0 +1,855 @@ +# @prefabs.tech/fastify-s3 — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-s3 + +# pnpm +pnpm add @prefabs.tech/fastify-s3 +``` + +Peer dependencies that must be installed alongside this package: + +```bash +pnpm add fastify fastify-plugin slonik zod \ + @prefabs.tech/fastify-config \ + @prefabs.tech/fastify-error-handler \ + @prefabs.tech/fastify-graphql \ + @prefabs.tech/fastify-slonik +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# From the repo root +pnpm install + +# Run tests for this package only +pnpm --filter @prefabs.tech/fastify-s3 test + +# Build +pnpm --filter @prefabs.tech/fastify-s3 build +``` + +--- + +## Setup + +Register the plugin once. All later examples assume this setup is already in place. + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import Fastify from "fastify"; +import s3Plugin, { ajvFilePlugin } from "@prefabs.tech/fastify-s3"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; + +// Extend ApiConfig so TypeScript knows about config.s3 +// (the module augmentation in index.ts handles this automatically when you import the package) +import "@prefabs.tech/fastify-s3"; + +const fastify = Fastify({ + ajv: { + plugins: [ajvFilePlugin], // enable isFile keyword in route schemas + }, +}); + +// Register config plugin first (fastify-config peer dep) +await fastify.register(configPlugin, { + s3: { + clientConfig: { + region: "us-east-1", + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + }, + bucket: "my-app-uploads", + fileSizeLimitInBytes: 10 * 1024 * 1024, // 10 MB + filenameResolutionStrategy: "add-suffix", + table: { name: "files" }, // optional, "files" is the default + }, + rest: { enabled: true }, + graphql: { enabled: false }, +}); + +// Register slonik plugin (peer dep) before s3 plugin +await fastify.register(slonikPlugin); + +// Register the S3 plugin +await fastify.register(s3Plugin); + +await fastify.listen({ port: 3000 }); +``` + +--- + +## Base Libraries + +### `@aws-sdk/client-s3` — Modified + +Official docs: https://www.npmjs.com/package/@aws-sdk/client-s3 + +The `S3Client` class in this package wraps the AWS SDK `S3Client`. The raw `S3ClientConfig` type is re-exported for consumers that need it. Commands (`GetObjectCommand`, `DeleteObjectCommand`, `ListObjectsCommand`, `HeadObjectCommand`) are used internally and are not exposed directly. We add: + +- A mutable `bucket` property on the class so one instance can target multiple buckets. +- `get`, `upload`, `delete`, `getObjects`, `isFileExists`, and `generatePresignedUrl` convenience methods (see the S3Client section below). + +### `@aws-sdk/lib-storage` — Full Passthrough + +Official docs: https://www.npmjs.com/package/@aws-sdk/lib-storage + +`Upload` is used internally inside `S3Client.upload` to support multipart S3 uploads. No API surface from this library is exposed to consumers. + +### `@aws-sdk/s3-request-presigner` — Full Passthrough + +Official docs: https://www.npmjs.com/package/@aws-sdk/s3-request-presigner + +`getSignedUrl` is used internally inside `S3Client.generatePresignedUrl`. Not exposed directly. + +### `@fastify/multipart` — Modified + +Official docs: https://www.npmjs.com/package/@fastify/multipart + +Registered automatically (when `config.rest.enabled` is `true`) with a fixed configuration: + +- `attachFieldsToBody: "keyValues"` — non-file fields are attached directly to `req.body`. +- `sharedSchemaId: "fileSchema"` — registers the file JSON schema under this id. +- `limits.fileSize` set from `config.s3.fileSizeLimitInBytes`. +- An `onFile` hook converts each multipart file part into a `Multipart` object (`{ data: Buffer, encoding, filename, mimetype }`) before route handlers run. + +Consumers cannot change these options through this plugin; register `@fastify/multipart` manually if custom options are needed. + +### `graphql-upload-minimal` — Partial Passthrough + +Official docs: https://www.npmjs.com/package/graphql-upload-minimal + +`processRequest` and `UploadOptions` are used in the internal `graphqlUpload` plugin. The `FileUpload` and `Upload` types are re-exported as `GraphQLFileUpload` and `GraphQLUpload`. We add a `preValidation` hook that calls `processRequest` only when `req.graphqlFileUploadMultipart` is `true` (set by `multipartParserPlugin`). + +### `busboy` — Full Passthrough + +Official docs: https://www.npmjs.com/package/busboy + +Used internally in `processMultipartFormData` (called by `multipartParserPlugin`) to parse multipart bodies outside the GraphQL path. Not exposed to consumers. + +### `uuid` — Full Passthrough + +Official docs: https://www.npmjs.com/package/uuid + +`uuidv4()` is used inside `FileService` to generate fallback filenames. Not exposed to consumers. + +--- + +## Features + +### 1 — Main plugin registration + +Import the default export and register it with Fastify. The plugin uses `fastify-plugin` so decorators are not scoped. + +```typescript +import s3Plugin from "@prefabs.tech/fastify-s3"; + +await fastify.register(s3Plugin); +// Runs DB migration, registers multipart (if REST enabled), +// registers GraphQL upload hook (if GraphQL enabled). +``` + +### 2 — Automatic database migration + +On every registration the plugin issues `CREATE TABLE IF NOT EXISTS` for the files table. No manual migration command is needed. The table name comes from `config.s3.table.name` (default `"files"`). + +### 3 — Conditional REST multipart + +When `config.rest.enabled` is `true`, `@fastify/multipart` is registered automatically. File parts are available on `req.body` as `Multipart` objects. + +```typescript +// config +rest: { + enabled: true; +} + +// Route — file is ready as a Multipart object on req.body +fastify.post( + "/upload", + { + schema: { + body: { + type: "object", + properties: { + file: { isFile: true }, // single file + attachments: { + // array of files + type: "array", + items: { isFile: true }, + }, + }, + required: ["file"], + }, + }, + }, + async (request) => { + const { file } = request.body as { file: Multipart }; + // file.data is a Buffer, file.filename, file.mimetype are strings + }, +); +``` + +### 4 — Conditional GraphQL upload registration + +When `config.graphql?.enabled` is `true`, the plugin registers a `preValidation` hook that calls `processRequest` from `graphql-upload-minimal` for multipart GraphQL requests. You must also register `multipartParserPlugin` to set the `graphqlFileUploadMultipart` flag. + +```typescript +// config +graphql: { enabled: true, path: "/graphql" } + +// Also register multipartParserPlugin (see Feature 8) +await fastify.register(multipartParserPlugin); +await fastify.register(s3Plugin); +``` + +### 5 — `S3Config` configuration shape + +Provide `s3` inside the config plugin options: + +```typescript +import type { S3Config } from "@prefabs.tech/fastify-s3"; + +const s3Config: S3Config = { + clientConfig: { + region: "eu-west-1", + credentials: { + accessKeyId: "AKIA...", + secretAccessKey: "...", + }, + }, + bucket: "primary-bucket", // or { avatars: "avatars-bucket", docs: "docs-bucket" } + fileSizeLimitInBytes: 5 * 1024 * 1024, // 5 MB + filenameResolutionStrategy: "add-suffix", + table: { name: "uploaded_files" }, +}; +``` + +### 6 — Module augmentation of `@prefabs.tech/fastify-config` + +Importing `@prefabs.tech/fastify-s3` automatically extends `ApiConfig` with the `s3` key. No manual interface merging is required. + +```typescript +import "@prefabs.tech/fastify-s3"; // side-effect: augments ApiConfig + +// Now TypeScript knows about fastify.config.s3 +const bucket = fastify.config.s3.bucket; +``` + +### 7 — `ajvFilePlugin` — custom `isFile` AJV keyword + +Pass the plugin to Fastify's AJV options to enable the `isFile` keyword in route body schemas. + +```typescript +import Fastify from "fastify"; +import { ajvFilePlugin } from "@prefabs.tech/fastify-s3"; + +const fastify = Fastify({ + ajv: { plugins: [ajvFilePlugin] }, +}); + +// Use in a route schema: +fastify.post("/upload", { + schema: { + body: { + type: "object", + properties: { + document: { isFile: true }, + images: { type: "array", items: { isFile: true } }, + }, + }, + }, + handler: async (request) => { + /* ... */ + }, +}); +``` + +At runtime, `isFile: true` validates that the value has `data`, `filename`, and `mimetype` properties. It also rewrites the schema to `{ type: "string", format: "binary" }` so Swagger UI renders a file picker. + +### 8 — `multipartParserPlugin` — catch-all content-type parser + +Register this plugin when your application handles both GraphQL file uploads and REST file uploads, or whenever you need busboy-based multipart parsing outside of `@fastify/multipart`. + +```typescript +import { multipartParserPlugin } from "@prefabs.tech/fastify-s3"; + +await fastify.register(multipartParserPlugin); +``` + +For multipart requests to the GraphQL path, it sets `req.graphqlFileUploadMultipart = true` (the `graphqlUpload` preValidation hook checks this flag). For all other multipart requests it parses the body via Busboy and attaches fields and files to `req.body`. + +### 9 — `S3Client` class + +Use `S3Client` directly when you need raw S3 access outside the `FileService` abstraction. + +```typescript +import { S3Client } from "@prefabs.tech/fastify-s3"; +import type { S3ClientConfig } from "@prefabs.tech/fastify-s3"; + +const clientConfig: S3ClientConfig = { + region: "us-east-1", + credentials: { accessKeyId: "...", secretAccessKey: "..." }, +}; + +const client = new S3Client(clientConfig); +client.bucket = "my-bucket"; +``` + +### 10 — `S3Client.upload` + +```typescript +import { ReadStream, createReadStream } from "node:fs"; + +// Upload from a Buffer +const buffer = Buffer.from("hello world"); +await client.upload(buffer, "path/to/hello.txt", "text/plain"); + +// Upload from a ReadStream +const stream: ReadStream = createReadStream("/tmp/photo.jpg"); +await client.upload(stream, "images/photo.jpg", "image/jpeg"); +``` + +### 11 — `S3Client.get` + +```typescript +const { Body, ContentType } = await client.get("path/to/hello.txt"); +// Body is a Buffer, ContentType is e.g. "text/plain" +``` + +### 12 — `S3Client.delete` + +```typescript +const output = await client.delete("path/to/hello.txt"); +// output is DeleteObjectCommandOutput +``` + +### 13 — `S3Client.generatePresignedUrl` + +```typescript +// Default expiry: 3600 seconds +const url = await client.generatePresignedUrl( + "uploads/report.pdf", + "Q3 Report.pdf", +); + +// Custom expiry: 15 minutes +const shortUrl = await client.generatePresignedUrl( + "uploads/report.pdf", + "Q3 Report.pdf", + 900, +); +``` + +The URL includes `Content-Disposition: attachment; filename=""` so browsers trigger a download. + +### 14 — `S3Client.getObjects` + +```typescript +const result = await client.getObjects("uploads/2024/"); +// result.Contents lists all keys with that prefix +``` + +### 15 — `S3Client.isFileExists` + +```typescript +const exists = await client.isFileExists("uploads/photo.jpg"); +if (exists) { + console.log("File is already in S3"); +} +``` + +Returns `false` for `NotFound` errors and re-throws any other error. + +### 16 — `FileService` class + +`FileService` extends `BaseService` and inherits its full CRUD interface. Construct it with the Fastify `config` and a Slonik `database` connection. + +```typescript +import { FileService } from "@prefabs.tech/fastify-s3"; + +// Typically constructed inside a route or service layer: +const service = new FileService({ + config: fastify.config, + database: fastify.slonik, +}); +``` + +### 17 — `FileService.upload` + +```typescript +import type { FilePayload } from "@prefabs.tech/fastify-s3"; + +const payload: FilePayload = { + file: { + fileContent: { + data: Buffer.from("..."), + filename: "report.pdf", + mimetype: "application/pdf", + }, + fileFields: { + bucket: "docs-bucket", + uploadedAt: Date.now(), + uploadedById: "user-123", + }, + }, + options: { + path: "reports/2024", + filenameResolutionStrategy: "add-suffix", // overrides config-level default + }, +}; + +const file = await service.upload(payload); +// file contains the persisted DB record including id, key, originalFileName, etc. +``` + +### 18 — `FileService.download` + +```typescript +const result = await service.download(42); +// result.fileStream is a Buffer of the file's raw bytes +// result.mimeType is the Content-Type from S3 +// ...plus all columns from the files table + +// With an explicit bucket override: +const result2 = await service.download(42, { bucket: "archive-bucket" }); +``` + +### 19 — `FileService.deleteFile` + +```typescript +await service.deleteFile(42); +// Removes the DB record first, then deletes the S3 object. + +// Override bucket if stored metadata bucket is stale: +await service.deleteFile(42, { bucket: "old-bucket" }); +``` + +### 20 — `FileService.presignedUrl` + +```typescript +import type { PresignedUrlOptions } from "@prefabs.tech/fastify-s3"; + +const options: PresignedUrlOptions = { + signedUrlExpiresInSecond: 1800, // 30 minutes; default 3600 +}; + +const result = await service.presignedUrl(42, options); +console.log(result.url); // https://s3.amazonaws.com/... +``` + +### 21 — `FileService.key` computed property + +The S3 object key is built from `/`. A trailing `/` is added to `path` automatically if absent. + +```typescript +service.path = "images/avatars"; +service.filename = "user-99.jpg"; +console.log(service.key); // "images/avatars/user-99.jpg" + +service.path = "images/avatars/"; // already has trailing slash +console.log(service.key); // "images/avatars/user-99.jpg" +``` + +### 22 — `FileService.filename` UUID fallback + +When `filename` has not been set on the service, the getter generates a UUID-based name: + +```typescript +service.fileExtension = "png"; +// service.filename not set +console.log(service.filename); // e.g. "550e8400-e29b-41d4-a716-446655440000.png" + +// Explicit filename without extension — extension appended automatically: +service.filename = "avatar"; +service.fileExtension = "png"; +console.log(service.filename); // "avatar.png" +``` + +### 23 — `FileSqlFactory` and configurable table name + +`FileSqlFactory` overrides the `table` getter from `DefaultSqlFactory` to respect `config.s3.table.name`. You generally do not instantiate this directly — `FileService` uses it internally. + +```typescript +// config.s3.table.name = "uploaded_files" +// All FileService queries will target "uploaded_files" instead of "files" +``` + +### 24 — `convertStreamToBuffer` + +Internal utility used by `S3Client.get` to normalize a stream response into a `Buffer`: + +```typescript +const { Body, ContentType } = await client.get("uploads/report.pdf"); + +// Body is already converted to Buffer by the internal helper. +console.log(Body instanceof Buffer); // true +console.log(ContentType); +``` + +Note: `convertStreamToBuffer` is intentionally not part of the package's public export surface. + +### 25 — `getPreferredBucket` utility + +Controls which bucket wins when both `options.bucket` and `fileFields.bucket` are provided: + +```typescript +// Explicit bucketChoice +options: { + bucket: "archive", + bucketChoice: "optionsBucket", // "archive" wins +} + +options: { + bucketChoice: "fileFieldsBucket", // fileFields.bucket wins +} + +// No bucketChoice — fileFields.bucket takes precedence when both present +options: { + bucket: "archive", + // no bucketChoice +} +// fileFields.bucket wins if set +``` + +### 26 — `getFilenameWithSuffix` — add-suffix strategy detail + +When `filenameResolutionStrategy` is `"add-suffix"` and a collision is found, the service lists existing S3 objects with the same base name and picks the next numeric suffix: + +``` +Existing keys: report.pdf, report-1.pdf, report-2.pdf +→ New key: report-3.pdf +``` + +### 27 / 28 — `getBaseName` and `getFileExtension` + +Internally used to split filenames before applying suffix logic. Not part of the public exports. + +### 29 — `createFilesTableQuery` migration query + +Exported for consumers who manage their own migration tooling: + +```typescript +import { createFilesTableQuery } from "@prefabs.tech/fastify-s3"; + +const query = createFilesTableQuery(config); +await database.connect(async (conn) => conn.query(query)); +``` + +### 30 — `ERROR_CODES.FILE_NOT_FOUND` + +```typescript +import { ERROR_CODES } from "@prefabs.tech/fastify-s3"; + +try { + await service.download(999); +} catch (err: unknown) { + if (err instanceof CustomError && err.code === ERROR_CODES.FILE_NOT_FOUND) { + reply.status(404).send({ error: "File not found" }); + } +} +``` + +### 31 — `ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3` + +```typescript +import { ERROR_CODES } from "@prefabs.tech/fastify-s3"; + +// config.s3.filenameResolutionStrategy = "error" +try { + await service.upload(payload); +} catch (err: unknown) { + if ( + err instanceof CustomError && + err.code === ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3 + ) { + reply.status(409).send({ error: "A file with that name already exists" }); + } +} +``` + +### 32–41 — Type exports + +All types are importable from the package root: + +```typescript +import type { + S3Config, + FilePayload, + FilePayloadOptions, + Multipart, + FilenameResolutionStrategy, + BucketChoice, + File, + FileCreateInput, + FileUpdateInput, + GraphQLFileUpload, + GraphQLUpload, + S3ClientConfig, +} from "@prefabs.tech/fastify-s3"; +``` + +--- + +## Use Cases + +### Use Case 1 — REST file upload with route validation + +Accept a single file via a REST endpoint and store it in S3, persisting metadata to the database. + +```typescript +import Fastify from "fastify"; +import s3Plugin, { ajvFilePlugin, FileService } from "@prefabs.tech/fastify-s3"; +import type { Multipart, FilePayload } from "@prefabs.tech/fastify-s3"; + +const fastify = Fastify({ ajv: { plugins: [ajvFilePlugin] } }); +// ... register config, slonik, then s3Plugin ... + +fastify.post<{ + Body: { document: Multipart; description: string }; +}>( + "/documents", + { + schema: { + body: { + type: "object", + properties: { + document: { isFile: true }, + description: { type: "string" }, + }, + required: ["document"], + }, + }, + }, + async (request, reply) => { + const { document, description } = request.body; + + const service = new FileService({ + config: fastify.config, + database: fastify.slonik, + }); + + const payload: FilePayload = { + file: { + fileContent: document, + fileFields: { + description, + uploadedAt: Date.now(), + uploadedById: request.user?.id, + }, + }, + options: { + path: "documents", + }, + }; + + const file = await service.upload(payload); + return reply.status(201).send(file); + }, +); +``` + +### Use Case 2 — Multiple bucket routing per upload type + +Use a named-bucket map and `bucketChoice` to route different file types to different buckets. + +```typescript +// Config +s3: { + clientConfig: { region: "us-east-1", credentials: { ... } }, + bucket: { + avatars: "my-app-avatars", + documents: "my-app-docs", + }, +} + +// Upload handler — avatar goes to avatars bucket +const payload: FilePayload = { + file: { + fileContent: avatarFile, + fileFields: { + bucket: fastify.config.s3.bucket["avatars"], + uploadedAt: Date.now(), + }, + }, + options: { + bucketChoice: "fileFieldsBucket", + path: `users/${userId}/avatar`, + }, +}; +const record = await service.upload(payload); +``` + +### Use Case 3 — Generating a time-limited download URL + +Return a presigned URL so the client can download a file directly from S3 without proxying through your server. + +```typescript +fastify.get<{ Params: { id: string } }>( + "/files/:id/download-url", + async (request, reply) => { + const service = new FileService({ + config: fastify.config, + database: fastify.slonik, + }); + + const { url } = await service.presignedUrl(Number(request.params.id), { + signedUrlExpiresInSecond: 300, // 5 minutes + }); + + return reply.send({ url }); + }, +); +``` + +### Use Case 4 — Streaming a file back through the server + +Retrieve the raw bytes from S3 and send them in the response. + +```typescript +fastify.get<{ Params: { id: string } }>( + "/files/:id", + async (request, reply) => { + const service = new FileService({ + config: fastify.config, + database: fastify.slonik, + }); + + const { fileStream, mimeType, originalFileName } = await service.download( + Number(request.params.id), + ); + + return reply + .header("Content-Type", mimeType ?? "application/octet-stream") + .header( + "Content-Disposition", + `attachment; filename="${originalFileName}"`, + ) + .send(fileStream); + }, +); +``` + +### Use Case 5 — Deleting a file + +Remove both the S3 object and the database record in one call. + +```typescript +fastify.delete<{ Params: { id: string } }>( + "/files/:id", + async (request, reply) => { + const service = new FileService({ + config: fastify.config, + database: fastify.slonik, + }); + + await service.deleteFile(Number(request.params.id)); + return reply.status(204).send(); + }, +); +``` + +### Use Case 6 — GraphQL file upload + +Enable GraphQL multipart support and handle file uploads in a GraphQL mutation. + +```typescript +// Registration (order matters) +await fastify.register(configPlugin, { + s3: { clientConfig: { ... }, bucket: "uploads", fileSizeLimitInBytes: 10_000_000 }, + rest: { enabled: false }, + graphql: { enabled: true, path: "/graphql" }, +}); +await fastify.register(slonikPlugin); +await fastify.register(multipartParserPlugin); // must come before s3Plugin +await fastify.register(s3Plugin); +await fastify.register(graphqlPlugin); // your Mercurius/graphql plugin + +// GraphQL resolver +const resolvers = { + Mutation: { + uploadFile: async (_: unknown, args: { file: GraphQLUpload }) => { + const { createReadStream, filename, mimetype } = await args.file; + + const service = new FileService({ config: fastify.config, database: fastify.slonik }); + + return service.upload({ + file: { + fileContent: { + data: createReadStream(), + filename, + mimetype, + }, + fileFields: { uploadedAt: Date.now() }, + }, + }); + }, + }, +}; +``` + +### Use Case 7 — Collision-safe uploads with automatic suffix + +Configure `filenameResolutionStrategy: "add-suffix"` globally and upload files whose names may collide. + +```typescript +// Config +s3: { + clientConfig: { ... }, + bucket: "reports", + filenameResolutionStrategy: "add-suffix", +} + +// First upload → stored as "annual-report.pdf" +// Second upload of same name → stored as "annual-report-1.pdf" +// Third → "annual-report-2.pdf", etc. +const service = new FileService({ config: fastify.config, database: fastify.slonik }); + +await service.upload({ + file: { + fileContent: { data: pdfBuffer, filename: "annual-report.pdf", mimetype: "application/pdf" }, + fileFields: { uploadedAt: Date.now() }, + }, +}); +``` + +### Use Case 8 — Using `S3Client` directly + +Bypass `FileService` for ad-hoc S3 operations (e.g. listing objects, checking existence) without database involvement. + +```typescript +import { S3Client } from "@prefabs.tech/fastify-s3"; + +const client = new S3Client(fastify.config.s3.clientConfig); +client.bucket = "my-bucket"; + +// Check before uploading +const exists = await client.isFileExists("exports/data.csv"); +if (!exists) { + await client.upload(csvBuffer, "exports/data.csv", "text/csv"); +} + +// List all exports +const { Contents } = await client.getObjects("exports/"); +const keys = Contents?.map((obj) => obj.Key) ?? []; +``` + +### Use Case 9 — Custom migration integration + +Run the migration query inside your own migration pipeline instead of relying on the automatic plugin startup migration. + +```typescript +import { createFilesTableQuery } from "@prefabs.tech/fastify-s3"; + +// Inside your custom migration runner: +await database.connect(async (connection) => { + await connection.query(createFilesTableQuery(config)); +}); +``` diff --git a/packages/s3/README.md b/packages/s3/README.md index 2b0994da0..052dc6d5b 100644 --- a/packages/s3/README.md +++ b/packages/s3/README.md @@ -2,23 +2,44 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of S3 in a fastify API. +## Why this plugin? + +Handling file uploads in a full-stack context requires substantially more effort than simply pushing byte streams to an S3 bucket via the AWS SDK. You must parse multipart requests, handle potential filename collisions securely, stream data to S3, and immediately synchronize metadata flags to your database. We created this plugin to: + +- **Automate the Full Upload Lifecycle**: From intercepting `multipart/form-data` chunks (via internal parsers), writing to S3, and saving strict structured metadata natively into our `@prefabs.tech/fastify-slonik` powered databases—this plugin handles the entire flow. +- **Standardize Duplication Strategies**: It provides out-of-the-box mechanisms (`error`, `add-suffix`, `override`) to elegantly handle duplicate filenames with zero effort. +- **Bridge REST & GraphQL**: The plugin provides specialized parsers (`ajvFilePlugin` and `multipartParserPlugin`) ensuring that file uploads are supported natively and documented correctly via Swagger (for REST APIs) and GraphQL simultaneously. + +### Design Decisions: Why not @aws-sdk/client-s3 and @fastify/multipart directly? + +- **Too Much Boilerplate**: While those granular tools are fantastic, manually aggregating them to handle incoming parsed streams, S3 buffering, database synchronization, and Swagger schema injection per-route results in massive duplication of boilerplate code across microservices. +- **Ecosystem Homogenization**: This plugin strictly binds the AWS SDK into our ecosystem's configuration (`fastify-config`) and database architecture (`fastify-slonik`), affording you a unified `FileService` that is ready to execute uploads and metadata queries perfectly right after registration. + ## Requirements -* [@prefabs.tech/fastify-config](../config/) -* [@prefabs.tech/fastify-slonik](../slonik/) +Peer dependencies (install compatible versions — see [package.json](./package.json)): + +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-error-handler](../error-handler/) +- [@prefabs.tech/fastify-graphql](../graphql/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [`fastify`](https://www.npmjs.com/package/fastify) +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) +- [`slonik`](https://www.npmjs.com/package/slonik) +- [`zod`](https://www.npmjs.com/package/zod) ## Installation Install with npm: ```bash -npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik @prefabs.tech/fastify-s3 +npm install @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-graphql @prefabs.tech/fastify-slonik @prefabs.tech/fastify-s3 fastify fastify-plugin slonik zod ``` Install with pnpm: ```bash -pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-slonik @prefabs.tech/fastify-s3 +pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fastify-error-handler @prefabs.tech/fastify-graphql @prefabs.tech/fastify-slonik @prefabs.tech/fastify-s3 fastify fastify-plugin slonik zod ``` ## Usage @@ -27,47 +48,44 @@ pnpm add --filter "@scope/project" @prefabs.tech/fastify-config @prefabs.tech/fa When using AWS S3, you are required to enable the following permissions: -***Required Permission:*** +**_Required Permission:_** - GetObject Permission - GetObjectAttributes Permission - PutObject Permission -***Optional Permissions:*** +**_Optional Permissions:_** - ListBucket Permission - If you choose the `add-suffix` option for FilenameResolutionStrategy when dealing with duplicate files, then you have to enable this permission. - DeleteObject Permission - If you use the `deleteFile` method from the file service, you will need this permission - -***Sample S3 Permission:*** +**_Sample S3 Permission:_** ```json - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": "*", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::your-bucket" - }, - { - "Effect": "Allow", - "Principal": "*", - "Action": [ - "s3:DeleteObject", - "s3:GetObject", - "s3:GetObjectAttributes", - "s3:PutObject" - ], - "Resource": "arn:aws:s3:::your-bucket/*" - } - ] - } +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["s3:ListBucket"], + "Effect": "Allow", + "Principal": "*", + "Resource": "arn:aws:s3:::your-bucket" + }, + { + "Action": [ + "s3:DeleteObject", + "s3:GetObject", + "s3:GetObjectAttributes", + "s3:PutObject" + ], + "Effect": "Allow", + "Principal": "*", + "Resource": "arn:aws:s3:::your-bucket/*" + } + ] +} ``` ### Register plugin @@ -76,7 +94,9 @@ Register the file fastify-s3 package with your Fastify instance: ```typescript import configPlugin from "@prefabs.tech/fastify-config"; -import s3Plugin, { multipartParserPlugin } from "@prefabs.tech/fastify-s3"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import graphqlPlugin from "@prefabs.tech/fastify-graphql"; +import s3Plugin from "@prefabs.tech/fastify-s3"; import slonikPlugin from "@prefabs.tech/fastify-slonik"; import Fastify from "fastify"; @@ -91,17 +111,23 @@ const start = async () => { // Register config plugin await fastify.register(configPlugin, { config }); + await fastify.register(errorHandlerPlugin, { + stackTrace: process.env.NODE_ENV === "development", + }); + // Register database plugin await fastify.register(slonikPlugin, config.slonik); - - // Register fastify-s3 plugin + + await fastify.register(graphqlPlugin, config.graphql); + + // Register fastify-s3 plugin (see below for multipartParserPlugin when using GraphQL uploads) await fastify.register(s3Plugin); - + await fastify.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); -} +}; start(); ``` @@ -115,18 +141,18 @@ AWS S3 Config ```typescript const config: ApiConfig = { // ... other configurations - + s3: { + bucket: "" | { key: "value" }, // Specify your S3 bucket //... AWS S3 client config clientConfig: { credentials: { - accessKeyId: "accessKey", // Replace with your AWS access key - secretAccessKey: "secretKey", // Replace with your AWS secret key + accessKeyId: "accessKey", // Replace with your AWS access key + secretAccessKey: "secretKey", // Replace with your AWS secret key }, - region: "ap-southeast-1" // Replace with your AWS region + region: "ap-southeast-1", // Replace with your AWS region }, - bucket: "" | { key: "value" }, // Specify your S3 bucket - } + }, }; ``` @@ -146,8 +172,9 @@ Minio Service Config ```typescript const config: ApiConfig = { // ... other configurations - + s3: { + bucket: "yourMinioBucketName", clientConfig: { credentials: { accessKeyId: "yourMinioAccessKey", @@ -155,12 +182,10 @@ const config: ApiConfig = { }, endpoint: "http://your-minio-server-url:port", // Replace with your Minio server URL forcePathStyle: true, // Set to true if your Minio server uses path-style URLs - region: "" // For Minio, you can leave the region empty or specify it based on your setup + region: "", // For Minio, you can leave the region empty or specify it based on your setup }, - bucket: "yourMinioBucketName", - } + }, }; - ``` To add a custom table name: @@ -168,15 +193,14 @@ To add a custom table name: ```typescript const config: ApiConfig = { // ... other configurations - + s3: { //... AWS S3 client config table: { - name: "new-table-name" // You can set a custom table name here (default: "files") - } - } + name: "new-table-name", // You can set a custom table name here (default: "files") + }, + }, }; - ``` To limit the file size while uploading: @@ -184,13 +208,12 @@ To limit the file size while uploading: ```typescript const config: ApiConfig = { // ... other configurations - + s3: { //... AWS S3 client config - fileSizeLimitInBytes: 10485760 - } + fileSizeLimitInBytes: 10485760, + }, }; - ``` To handle duplicate filenames: @@ -201,18 +224,18 @@ To handle duplicate filenames: - `override`: This is the default option and it overrides the file if the file name already exists. ```typescript - fileService.upload({ + fileService.upload({ + // ... other options + options: { // ... other options - options: { - // ... other options - filenameResolutionStrategy: "add-suffix", - }, - }); + filenameResolutionStrategy: "add-suffix", + }, + }); ``` ## Using GraphQL -This package supports integration with [@prefabs.tech/fastify-graphql](../graphql/). +This package supports integration with [@prefabs.tech/fastify-graphql](../graphql/). Register additional `multipartParserPlugin` plugin with the fastify instance as shown below: @@ -230,10 +253,10 @@ const start = async () => { const fastify = Fastify({ logger: config.logger, }); - + // Register config plugin await fastify.register(configPlugin, { config }); - + // Register database plugin await fastify.register(slonikPlugin, config.slonik); @@ -247,8 +270,8 @@ const start = async () => { await fastify.register(s3Plugin); await await.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); } @@ -257,8 +280,8 @@ start(); **Note**: Register the `multipartParserPlugin` if you're using GraphQL or both GraphQL and REST, as it's required. Make sure to place the registration of the `multipartParserPlugin` above the `graphqlPlugin`. - ## JSON Schema with Swagger + If you want to use @prefabs.tech/fastify-s3 with @fastify/swagger and @fastify/swagger-ui or @prefabs.tech/swagger you must add a new type called `isFile` and use a custom instance of a validator compiler ```typescript @@ -282,10 +305,10 @@ const start = async () => { plugins: [ajvFilePlugin], }, }); - + // Register config plugin await fastify.register(configPlugin, { config }); - + // Register database plugin await fastify.register(slonikPlugin, config.slonik); @@ -300,15 +323,15 @@ const start = async () => { fastify.post('/upload/file', { schema: { - description: "Upload a file", - tags: ["file"], - consumes: ["multipart/form-data"], body: { - type: "object", properties: { file: { isFile: true }, }, + type: "object", }, + consumes: ["multipart/form-data"], + description: "Upload a file", + tags: ["file"], } }, function (req, reply) { console.log({ body: req.body }) diff --git a/packages/s3/package.json b/packages/s3/package.json index 7ef42e238..fb96b96bb 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-s3", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify S3 plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/s3#readme", "repository": { @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-s3.cjs", "module": "./dist/prefabs-tech-fastify-s3.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -29,43 +31,43 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { - "@aws-sdk/client-s3": "3.989.0", - "@aws-sdk/lib-storage": "3.989.0", - "@aws-sdk/s3-request-presigner": "3.989.0", + "@aws-sdk/client-s3": "3.1042.0", + "@aws-sdk/lib-storage": "3.1042.0", + "@aws-sdk/s3-request-presigner": "3.1042.0", "@fastify/multipart": "9.4.0", "@types/busboy": "1.5.4", "@types/uuid": "9.0.8", - "ajv": "8.17.1", + "ajv": "8.20.0", "busboy": "1.6.0", "graphql-upload-minimal": "1.6.4", "uuid": "9.0.1" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/fastify-error-handler": "0.93.5", - "@prefabs.tech/fastify-graphql": "0.93.5", - "@prefabs.tech/fastify-slonik": "0.93.5", - "@prefabs.tech/tsconfig": "0.5.0", - "@types/node": "24.10.13", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/fastify-error-handler": "0.94.0", + "@prefabs.tech/fastify-graphql": "0.94.0", + "@prefabs.tech/fastify-slonik": "0.94.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@types/node": "24.10.15", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "graphql": "16.12.0", - "prettier": "3.8.1", + "graphql": "16.13.2", + "prettier": "3.8.3", "slonik": "46.8.0", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4", "zod": "3.25.76" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/fastify-error-handler": "0.93.5", - "@prefabs.tech/fastify-graphql": "0.93.5", - "@prefabs.tech/fastify-slonik": "0.93.5", - "fastify": ">=5.2.1", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/fastify-error-handler": "0.94.0", + "@prefabs.tech/fastify-graphql": "0.94.0", + "@prefabs.tech/fastify-slonik": "0.94.0", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1", "slonik": ">=46.1.0", "zod": ">=3.23.8" @@ -73,4 +75,4 @@ "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/s3/src/__test__/multipartParser.test.ts b/packages/s3/src/__test__/multipartParser.test.ts new file mode 100644 index 000000000..e0dc2ea0b --- /dev/null +++ b/packages/s3/src/__test__/multipartParser.test.ts @@ -0,0 +1,126 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// processMultipartFormData is our code — mock it to avoid real busboy parsing +// in unit tests, and to prevent the double-done side effect in source code. +vi.mock("../utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + processMultipartFormData: vi.fn( + (_req: unknown, _payload: unknown, done: (err: null) => void) => + /* slonik returns null so we allow it here */ /* eslint-disable-next-line unicorn/no-null */ + done(null), + ), + }; +}); + +const buildFastify = (graphqlConfig?: { enabled: boolean; path: string }) => { + const instance = Fastify({ logger: false }); + instance.addHook("onRequest", async (req) => { + (req as unknown as { config: unknown }).config = graphqlConfig + ? { graphql: graphqlConfig } + : {}; + }); + return instance; +}; + +describe("multipartParserPlugin", () => { + let fastify: FastifyInstance; + + afterEach(async () => fastify.close()); + + it("does not return 415 for unknown content types after registration", async () => { + fastify = buildFastify(); + const { default: plugin } = await import("../plugins/multipartParser"); + await fastify.register(plugin); + + fastify.post("/test", async () => ({})); + await fastify.ready(); + + // Without the * catch-all parser, Fastify returns 415 for unrecognised content types. + // With it registered, the request reaches the route handler normally. + const response = await fastify.inject({ + headers: { "content-type": "text/csv" }, + method: "POST", + payload: "a,b,c", + url: "/test", + }); + + expect(response.statusCode).not.toBe(415); + }); + + it("sets graphqlFileUploadMultipart on the request for multipart requests to the graphql path", async () => { + fastify = buildFastify({ enabled: true, path: "/graphql" }); + const { default: plugin } = await import("../plugins/multipartParser"); + await fastify.register(plugin); + + let capturedFlag: boolean | undefined; + fastify.post("/graphql", async (req) => { + capturedFlag = req.graphqlFileUploadMultipart; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "multipart/form-data; boundary=----abc" }, + method: "POST", + payload: "------abc--\r\n", + url: "/graphql", + }); + + expect(capturedFlag).toBe(true); + }); + + it("does not set graphqlFileUploadMultipart for multipart requests outside the graphql path", async () => { + const { processMultipartFormData } = await import("../utils"); + + fastify = buildFastify({ enabled: true, path: "/graphql" }); + const { default: plugin } = await import("../plugins/multipartParser"); + await fastify.register(plugin); + + let capturedFlag: boolean | undefined; + fastify.post("/upload", async (req) => { + capturedFlag = req.graphqlFileUploadMultipart; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "multipart/form-data; boundary=----abc" }, + method: "POST", + payload: "------abc--\r\n", + url: "/upload", + }); + + expect(capturedFlag).toBeUndefined(); + expect(processMultipartFormData).toHaveBeenCalled(); + }); + + it("does not set graphqlFileUploadMultipart when graphql is disabled", async () => { + fastify = buildFastify({ enabled: false, path: "/graphql" }); + const { default: plugin } = await import("../plugins/multipartParser"); + await fastify.register(plugin); + + let capturedFlag: boolean | undefined; + fastify.post("/graphql", async (req) => { + capturedFlag = req.graphqlFileUploadMultipart; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "multipart/form-data; boundary=----abc" }, + method: "POST", + payload: "------abc--\r\n", + url: "/graphql", + }); + + expect(capturedFlag).toBeUndefined(); + }); +}); diff --git a/packages/s3/src/__test__/plugin.test.ts b/packages/s3/src/__test__/plugin.test.ts new file mode 100644 index 000000000..3705b83a1 --- /dev/null +++ b/packages/s3/src/__test__/plugin.test.ts @@ -0,0 +1,242 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks (hoisted so vi.mock factories can reference them) ────────────────── + +const { graphqlUploadMock, runMigrationsMock } = vi.hoisted(() => ({ + graphqlUploadMock: vi.fn(async () => {}), + runMigrationsMock: vi.fn().mockResolvedValue(), +})); + +vi.mock("../migrations/runMigrations", () => ({ default: runMigrationsMock })); +vi.mock("../plugins/graphqlUpload", () => ({ default: graphqlUploadMock })); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const buildFastify = (configOverrides: Record = {}) => { + const fastify = Fastify({ logger: false }); + fastify.decorate("config", { + rest: { enabled: true }, + s3: { bucket: "test-bucket", clientConfig: {} }, + ...configOverrides, + }); + fastify.decorate("slonik", {}); + return fastify; +}; + +/** Minimal multipart/form-data body for inject tests (single file field). */ +const buildMultipartFileBody = ( + boundary: string, + fieldName: string, + filename: string, + fileBytes: Buffer, +): Buffer => { + const preamble = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\n` + + `Content-Type: application/octet-stream\r\n\r\n`, + "utf8", + ); + const closing = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"); + return Buffer.concat([preamble, fileBytes, closing]); +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("s3 plugin — initialization", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + afterEach(async () => fastify.close()); + + it("calls runMigrations on startup", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).toHaveBeenCalledOnce(); + }); + + it("passes slonik and config to runMigrations", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(runMigrationsMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ s3: expect.any(Object) }), + ); + }); +}); + +describe("s3 plugin — REST multipart registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + afterEach(async () => fastify.close()); + + it("registers @fastify/multipart when config.rest.enabled is true", async () => { + fastify = buildFastify({ rest: { enabled: true } }); + await fastify.register(plugin); + await fastify.ready(); + + // @fastify/multipart registers a multipart/form-data content-type parser + expect(fastify.hasContentTypeParser("multipart/form-data")).toBe(true); + }); + + it("does not register @fastify/multipart when config.rest.enabled is false", async () => { + fastify = buildFastify({ rest: { enabled: false } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasContentTypeParser("multipart/form-data")).toBe(false); + }); + + it("rejects multipart uploads larger than fileSizeLimitInBytes with 413", async () => { + const boundary = "----test-boundary-413"; + const limitBytes = 512; + const oversized = Buffer.alloc(limitBytes + 200, 7); + + fastify = buildFastify({ + s3: { + bucket: "test-bucket", + clientConfig: {}, + fileSizeLimitInBytes: limitBytes, + }, + }); + await fastify.register(plugin); + + fastify.post("/upload", async () => ({ ok: true })); + + await fastify.ready(); + + const response = await fastify.inject({ + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + method: "POST", + payload: buildMultipartFileBody(boundary, "doc", "big.bin", oversized), + url: "/upload", + }); + + expect(response.statusCode).toBe(413); + }); + + it("attaches normalised file objects to the body for multipart fields within the size limit", async () => { + const boundary = "----test-boundary-ok"; + const fileBytes = Buffer.from("hello-s3"); + + fastify = buildFastify({ + s3: { + bucket: "test-bucket", + clientConfig: {}, + fileSizeLimitInBytes: 50000, + }, + }); + await fastify.register(plugin); + + let body: unknown; + fastify.post("/upload", async (request) => { + body = request.body; + return {}; + }); + + await fastify.ready(); + + const response = await fastify.inject({ + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + method: "POST", + payload: buildMultipartFileBody(boundary, "doc", "note.txt", fileBytes), + url: "/upload", + }); + + expect(response.statusCode).toBe(200); + expect(body).toEqual({ + doc: { + data: fileBytes, + encoding: expect.any(String), + filename: "note.txt", + mimetype: "application/octet-stream", + }, + }); + }); +}); + +describe("s3 plugin — GraphQL upload registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + afterEach(async () => fastify.close()); + + it("registers the graphql upload plugin when config.graphql.enabled is true", async () => { + fastify = buildFastify({ + graphql: { enabled: true }, + rest: { enabled: false }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).toHaveBeenCalledOnce(); + }); + + it("does not register the graphql upload plugin when config.graphql is undefined", async () => { + fastify = buildFastify({ graphql: undefined, rest: { enabled: false } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).not.toHaveBeenCalled(); + }); + + it("does not register the graphql upload plugin when config.graphql.enabled is false", async () => { + fastify = buildFastify({ + graphql: { enabled: false }, + rest: { enabled: false }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).not.toHaveBeenCalled(); + }); + + it("passes fileSizeLimitInBytes as maxFileSize to the graphql upload plugin", async () => { + fastify = buildFastify({ + graphql: { enabled: true }, + rest: { enabled: false }, + s3: { fileSizeLimitInBytes: 5_000_000 }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ maxFileSize: 5_000_000 }), + expect.any(Function), + ); + }); + + it("passes Infinity as maxFileSize when fileSizeLimitInBytes is not set", async () => { + fastify = buildFastify({ + graphql: { enabled: true }, + rest: { enabled: false }, + s3: { bucket: "test-bucket", clientConfig: {} }, + }); + await fastify.register(plugin); + await fastify.ready(); + + expect(graphqlUploadMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ maxFileSize: Number.POSITIVE_INFINITY }), + expect.any(Function), + ); + }); +}); diff --git a/packages/s3/src/__test__/s3Client.test.ts b/packages/s3/src/__test__/s3Client.test.ts new file mode 100644 index 000000000..e3c76ad00 --- /dev/null +++ b/packages/s3/src/__test__/s3Client.test.ts @@ -0,0 +1,211 @@ +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── + +const { mockGetSignedUrl, mockSend, mockUploadCtor, mockUploadDone } = + vi.hoisted(() => ({ + mockGetSignedUrl: vi.fn(), + mockSend: vi.fn(), + mockUploadCtor: vi.fn(), + mockUploadDone: vi.fn(), + })); + +vi.mock("@aws-sdk/client-s3", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + S3Client: vi.fn().mockImplementation(() => ({ send: mockSend })), + }; +}); + +vi.mock("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: mockGetSignedUrl, +})); + +vi.mock("@aws-sdk/lib-storage", () => ({ + Upload: vi.fn().mockImplementation((arguments_: unknown) => { + mockUploadCtor(arguments_); + return { done: mockUploadDone }; + }), +})); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("S3Client — isFileExists", async () => { + const { default: S3ClientWrapper } = await import("../utils/s3Client"); + + let client: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + client = new S3ClientWrapper({}); + client.bucket = "test-bucket"; + }); + + it("returns true when the object exists in S3", async () => { + mockSend.mockResolvedValue({ ContentLength: 1024 }); + + const result = await client.isFileExists("avatars/photo.jpg"); + + expect(result).toBe(true); + }); + + it("returns false when S3 throws a NotFound error", async () => { + const notFound = Object.assign(new Error("Not Found"), { + name: "NotFound", + }); + mockSend.mockRejectedValue(notFound); + + const result = await client.isFileExists("avatars/missing.jpg"); + + expect(result).toBe(false); + }); + + it("rethrows errors that are not NotFound", async () => { + const accessDenied = Object.assign(new Error("Access Denied"), { + name: "AccessDenied", + }); + mockSend.mockRejectedValue(accessDenied); + + await expect(client.isFileExists("private/file.txt")).rejects.toThrow( + "Access Denied", + ); + }); +}); + +describe("S3Client — generatePresignedUrl", async () => { + const { default: S3ClientWrapper } = await import("../utils/s3Client"); + + let client: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetSignedUrl.mockResolvedValue("https://presigned.url/file.pdf"); + client = new S3ClientWrapper({}); + client.bucket = "test-bucket"; + }); + + it("uses a default expiry of 3600 seconds when none is provided", async () => { + await client.generatePresignedUrl("reports/q1.pdf", "Q1 Report.pdf"); + + expect(mockGetSignedUrl).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { expiresIn: 3600 }, + ); + }); + + it("uses the provided expiry when specified", async () => { + await client.generatePresignedUrl("reports/q1.pdf", "Q1 Report.pdf", 900); + + expect(mockGetSignedUrl).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { expiresIn: 900 }, + ); + }); + + it("sets ResponseContentDisposition with the original filename", async () => { + const { GetObjectCommand } = await import("@aws-sdk/client-s3"); + + await client.generatePresignedUrl("reports/q1.pdf", "Q1 Report.pdf"); + + const commandArgument = mockGetSignedUrl.mock.calls[0][1]; + expect(commandArgument).toBeInstanceOf(GetObjectCommand); + expect(commandArgument.input.ResponseContentDisposition).toBe( + 'attachment; filename="Q1 Report.pdf"', + ); + }); + + it("returns the signed URL from the presigner", async () => { + const url = await client.generatePresignedUrl("file.pdf", "file.pdf"); + expect(url).toBe("https://presigned.url/file.pdf"); + }); +}); + +describe("S3Client — object operations", async () => { + const { default: S3ClientWrapper } = await import("../utils/s3Client"); + + let client: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + client = new S3ClientWrapper({}); + client.bucket = "test-bucket"; + }); + + it("sends DeleteObjectCommand with bucket and key", async () => { + const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); + mockSend.mockResolvedValue({ DeleteMarker: true }); + + await client.delete("docs/report.pdf"); + + expect(mockSend).toHaveBeenCalledOnce(); + const commandArgument = mockSend.mock.calls[0][0]; + expect(commandArgument).toBeInstanceOf(DeleteObjectCommand); + expect(commandArgument.input).toEqual({ + Bucket: "test-bucket", + Key: "docs/report.pdf", + }); + }); + + it("sends GetObjectCommand and returns buffered body with content type", async () => { + const { GetObjectCommand } = await import("@aws-sdk/client-s3"); + mockSend.mockResolvedValue({ + Body: Readable.from([Buffer.from("hello"), Buffer.from(" world")]), + ContentType: "text/plain", + }); + + const response = await client.get("docs/report.txt"); + + expect(mockSend).toHaveBeenCalledOnce(); + const commandArgument = mockSend.mock.calls[0][0]; + expect(commandArgument).toBeInstanceOf(GetObjectCommand); + expect(commandArgument.input).toEqual({ + Bucket: "test-bucket", + Key: "docs/report.txt", + }); + expect(response.Body.toString()).toBe("hello world"); + expect(response.ContentType).toBe("text/plain"); + }); + + it("sends ListObjectsCommand with the requested prefix", async () => { + const { ListObjectsCommand } = await import("@aws-sdk/client-s3"); + mockSend.mockResolvedValue({ Contents: [] }); + + await client.getObjects("docs/report"); + + expect(mockSend).toHaveBeenCalledOnce(); + const commandArgument = mockSend.mock.calls[0][0]; + expect(commandArgument).toBeInstanceOf(ListObjectsCommand); + expect(commandArgument.input).toEqual({ + Bucket: "test-bucket", + Prefix: "docs/report", + }); + }); + + it("creates Upload with S3 params and returns done() result", async () => { + mockUploadDone.mockResolvedValue({ ETag: "etag-value" }); + const payload = Buffer.from("content"); + + const result = await client.upload( + payload, + "docs/report.txt", + "text/plain", + ); + + expect(mockUploadCtor).toHaveBeenCalledOnce(); + expect(mockUploadCtor).toHaveBeenCalledWith({ + client: expect.anything(), + params: { + Body: payload, + Bucket: "test-bucket", + ContentType: "text/plain", + Key: "docs/report.txt", + }, + }); + expect(mockUploadDone).toHaveBeenCalledOnce(); + expect(result).toEqual({ ETag: "etag-value" }); + }); +}); diff --git a/packages/s3/src/__test__/service.test.ts b/packages/s3/src/__test__/service.test.ts new file mode 100644 index 000000000..17fd81f93 --- /dev/null +++ b/packages/s3/src/__test__/service.test.ts @@ -0,0 +1,356 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { File } from "../types/file"; + +import { ERROR_CODES } from "../constants"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── + +const { mockS3 } = vi.hoisted(() => ({ + mockS3: { + bucket: "" as string, + delete: vi.fn(), + generatePresignedUrl: vi.fn(), + get: vi.fn(), + getObjects: vi.fn(), + isFileExists: vi.fn(), + upload: vi.fn(), + }, +})); + +vi.mock("../utils/s3Client", () => ({ + default: vi.fn(() => mockS3), +})); + +vi.mock("@prefabs.tech/fastify-slonik", () => { + class MockBaseService { + config: ApiConfig; + constructor(config: ApiConfig, ...arguments_: unknown[]) { + void arguments_; + this.config = config; + } + async create(...arguments_: unknown[]): Promise { + void arguments_; + return undefined; + } + async delete(...arguments_: unknown[]): Promise { + void arguments_; + return undefined; + } + async findById(...arguments_: unknown[]): Promise { + void arguments_; + return undefined; + } + } + class MockDefaultSqlFactory { + config: ApiConfig; + get table() { + return "files"; + } + constructor(config: ApiConfig) { + this.config = config; + } + } + return { + BaseService: MockBaseService, + DefaultSqlFactory: MockDefaultSqlFactory, + formatDate: (d: Date) => d.toISOString(), + }; +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const buildConfig = (s3Overrides: Record = {}): ApiConfig => + ({ + s3: { + bucket: "test-bucket", + clientConfig: {}, + ...s3Overrides, + }, + }) as unknown as ApiConfig; + +const mockFile: File = { + bucket: "test-bucket", + createdAt: Date.now(), + id: 1, + key: "test/file.txt", + originalFileName: "file.txt", + updatedAt: Date.now(), + uploadedAt: Date.now(), +}; + +const makePayload = (overrides: Record = {}) => ({ + file: { + fileContent: { + data: Buffer.from("data"), + encoding: "utf8", + filename: "report.pdf", + mimetype: "application/pdf", + }, + fileFields: {}, + }, + ...overrides, +}); + +// ── filename getter ─────────────────────────────────────────────────────────── + +describe("FileService — filename getter", async () => { + const { default: FileService } = await import("../model/files/service"); + + it("generates a UUID filename when none is set", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "pdf"; + expect(service.filename).toMatch( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}\.pdf$/i, + ); + }); + + it("appends the extension when the set filename lacks it", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "pdf"; + service.filename = "report"; + expect(service.filename).toBe("report.pdf"); + }); + + it("returns filename unchanged when it already ends with the extension", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "pdf"; + service.filename = "report.pdf"; + expect(service.filename).toBe("report.pdf"); + }); +}); + +// ── key getter ──────────────────────────────────────────────────────────────── + +describe("FileService — key getter", async () => { + const { default: FileService } = await import("../model/files/service"); + + it("returns just the filename when no path is set", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "txt"; + service.filename = "notes.txt"; + expect(service.key).toBe("notes.txt"); + }); + + it("appends a trailing slash to path before building the key", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "txt"; + service.filename = "notes.txt"; + service.path = "uploads/docs"; + expect(service.key).toBe("uploads/docs/notes.txt"); + }); + + it("does not double-slash when path already ends with /", () => { + const service = new FileService(buildConfig(), {}); + service.fileExtension = "txt"; + service.filename = "notes.txt"; + service.path = "uploads/docs/"; + expect(service.key).toBe("uploads/docs/notes.txt"); + }); +}); + +// ── upload ──────────────────────────────────────────────────────────────────── + +describe("FileService — upload", async () => { + const { default: FileService } = await import("../model/files/service"); + + let service: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + mockS3.isFileExists.mockResolvedValue(false); + mockS3.upload.mockResolvedValue({ ETag: "etag" }); + mockS3.getObjects.mockResolvedValue({ Contents: [] }); + service = new FileService(buildConfig(), {}); + }); + + it("throws FILE_ALREADY_EXISTS_IN_S3_ERROR when strategy is 'error' and file exists", async () => { + mockS3.isFileExists.mockResolvedValue(true); + + await expect( + service.upload({ + ...makePayload(), + options: { filenameResolutionStrategy: "error" }, + }), + ).rejects.toMatchObject({ code: ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3 }); + }); + + it("uses config-level strategy when no per-upload strategy is provided", async () => { + mockS3.isFileExists.mockResolvedValue(true); + service = new FileService( + buildConfig({ filenameResolutionStrategy: "error" }), + {}, + ); + + await expect(service.upload(makePayload())).rejects.toMatchObject({ + code: ERROR_CODES.FILE_ALREADY_EXISTS_IN_S3, + }); + }); + + it("per-upload filenameResolutionStrategy overrides config-level strategy", async () => { + mockS3.isFileExists.mockResolvedValue(true); + service = new FileService( + buildConfig({ filenameResolutionStrategy: "error" }), + {}, + ); + vi.spyOn(service, "create").mockResolvedValue(mockFile); + + // Per-upload "add-suffix" overrides config "error" — no throw + await expect( + service.upload({ + ...makePayload(), + options: { filenameResolutionStrategy: "add-suffix" }, + }), + ).resolves.not.toThrow(); + }); + + it("appends a numeric suffix when strategy is 'add-suffix' and file exists", async () => { + mockS3.isFileExists.mockResolvedValue(true); + mockS3.getObjects.mockResolvedValue({ + Contents: [{ Key: "report-1.pdf" }], + }); + + const createSpy = vi.spyOn(service, "create").mockResolvedValue(mockFile); + + await service.upload({ + ...makePayload(), + options: { filenameResolutionStrategy: "add-suffix" }, + }); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ key: expect.stringMatching(/-\d+\.pdf$/) }), + ); + }); + + it("uploads unconditionally when strategy is 'overwrite' and file exists", async () => { + mockS3.isFileExists.mockResolvedValue(true); + vi.spyOn(service, "create").mockResolvedValue(mockFile); + + await expect( + service.upload({ + ...makePayload(), + options: { filenameResolutionStrategy: "overwrite" }, + }), + ).resolves.not.toThrow(); + + expect(mockS3.upload).toHaveBeenCalledOnce(); + }); + + it("skips the conflict check when file does not exist and uploads directly", async () => { + vi.spyOn(service, "create").mockResolvedValue(mockFile); + + await service.upload(makePayload()); + + expect(mockS3.upload).toHaveBeenCalledOnce(); + expect(mockS3.getObjects).not.toHaveBeenCalled(); + }); +}); + +// ── download ────────────────────────────────────────────────────────────────── + +describe("FileService — download", async () => { + const { default: FileService } = await import("../model/files/service"); + + let service: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + service = new FileService(buildConfig(), {}); + }); + + it("throws FILE_NOT_FOUND_ERROR when the file is not in the DB", async () => { + vi.spyOn(service, "findById").mockResolvedValue(); + + await expect(service.download(999)).rejects.toMatchObject({ + code: ERROR_CODES.FILE_NOT_FOUND, + }); + }); + + it("returns file metadata with fileStream and mimeType when found", async () => { + vi.spyOn(service, "findById").mockResolvedValue(mockFile); + mockS3.get.mockResolvedValue({ + Body: Buffer.from("content"), + ContentType: "image/png", + }); + + const result = await service.download(1); + + expect(result.fileStream).toBeDefined(); + expect(result.mimeType).toBe("image/png"); + expect(result.key).toBe(mockFile.key); + }); +}); + +// ── presignedUrl ────────────────────────────────────────────────────────────── + +describe("FileService — presignedUrl", async () => { + const { default: FileService } = await import("../model/files/service"); + + let service: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + service = new FileService(buildConfig(), {}); + }); + + it("throws FILE_NOT_FOUND_ERROR when the file is not in the DB", async () => { + vi.spyOn(service, "findById").mockResolvedValue(); + + await expect(service.presignedUrl(999, {})).rejects.toMatchObject({ + code: ERROR_CODES.FILE_NOT_FOUND, + }); + }); + + it("returns file metadata with a signed URL when found", async () => { + vi.spyOn(service, "findById").mockResolvedValue(mockFile); + mockS3.generatePresignedUrl.mockResolvedValue( + "https://signed.url/file.txt", + ); + + const result = await service.presignedUrl(1, {}); + + expect(result.url).toBe("https://signed.url/file.txt"); + expect(result.id).toBe(mockFile.id); + }); +}); + +// ── deleteFile ──────────────────────────────────────────────────────────────── + +describe("FileService — deleteFile", async () => { + const { default: FileService } = await import("../model/files/service"); + + let service: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + service = new FileService(buildConfig(), {}); + }); + + it("throws FILE_NOT_FOUND_ERROR when the file is not in the DB", async () => { + vi.spyOn(service, "findById").mockResolvedValue(); + + await expect(service.deleteFile(999)).rejects.toMatchObject({ + code: ERROR_CODES.FILE_NOT_FOUND, + }); + }); + + it("deletes the S3 object after the DB record is removed", async () => { + vi.spyOn(service, "findById").mockResolvedValue(mockFile); + vi.spyOn(service, "delete").mockResolvedValue(true); + + await service.deleteFile(1); + + expect(mockS3.delete).toHaveBeenCalledWith(mockFile.key); + }); + + it("does not delete from S3 when the DB deletion returns falsy", async () => { + vi.spyOn(service, "findById").mockResolvedValue(mockFile); + vi.spyOn(service, "delete").mockResolvedValue(false); + + await service.deleteFile(1); + + expect(mockS3.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/s3/src/__test__/sqlFactory.test.ts b/packages/s3/src/__test__/sqlFactory.test.ts new file mode 100644 index 000000000..e4e985db0 --- /dev/null +++ b/packages/s3/src/__test__/sqlFactory.test.ts @@ -0,0 +1,106 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { Database } from "@prefabs.tech/fastify-slonik"; + +import { describe, expect, it, vi } from "vitest"; + +import { createFilesTableQuery } from "../migrations/queries"; + +vi.mock("@prefabs.tech/fastify-slonik", () => { + class MockDefaultSqlFactory { + config: ApiConfig; + get table() { + return "files"; + } + constructor(config: ApiConfig) { + this.config = config; + } + } + return { DefaultSqlFactory: MockDefaultSqlFactory }; +}); + +// ── FileSqlFactory ──────────────────────────────────────────────────────────── + +describe("FileSqlFactory — table name", async () => { + const { default: FileSqlFactory } = await import("../model/files/sqlFactory"); + + it("uses config.s3.table.name when set", () => { + const factory = new FileSqlFactory({ + s3: { table: { name: "documents" } }, + } as unknown as ApiConfig); + expect(factory.table).toBe("documents"); + }); + + it("falls back to the default 'files' table name when not set", () => { + const factory = new FileSqlFactory({ s3: {} } as unknown as ApiConfig); + expect(factory.table).toBe("files"); + }); + + it("falls back to the default table name when s3.table is undefined", () => { + const factory = new FileSqlFactory({ + s3: { table: undefined }, + } as unknown as ApiConfig); + expect(factory.table).toBe("files"); + }); +}); + +// ── runMigrations ───────────────────────────────────────────────────────────── + +describe("runMigrations", async () => { + const { default: runMigrations } = + await import("../migrations/runMigrations"); + + it("calls database.connect once on startup", async () => { + const mockConnection = { query: vi.fn().mockResolvedValue() }; + const mockDatabase = { + connect: vi + .fn() + .mockImplementation( + async (function_: (c: typeof mockConnection) => void) => + function_(mockConnection), + ), + }; + + await runMigrations( + mockDatabase as unknown as Database, + { s3: {} } as unknown as ApiConfig, + ); + + expect(mockDatabase.connect).toHaveBeenCalledOnce(); + }); + + it("executes exactly one query per migration run", async () => { + const mockConnection = { query: vi.fn().mockResolvedValue() }; + const mockDatabase = { + connect: vi + .fn() + .mockImplementation( + async (function_: (c: typeof mockConnection) => void) => + function_(mockConnection), + ), + }; + + await runMigrations( + mockDatabase as unknown as Database, + { s3: {} } as unknown as ApiConfig, + ); + + expect(mockConnection.query).toHaveBeenCalledOnce(); + }); +}); + +describe("createFilesTableQuery", () => { + it("uses the default 'files' table name when config.s3.table.name is not set", () => { + const query = createFilesTableQuery({ s3: {} } as unknown as ApiConfig); + + expect(query.sql).toContain("CREATE TABLE IF NOT EXISTS"); + expect(query.sql).toContain('"files"'); + }); + + it("uses config.s3.table.name when provided", () => { + const query = createFilesTableQuery({ + s3: { table: { name: "documents" } }, + } as unknown as ApiConfig); + + expect(query.sql).toContain('"documents"'); + }); +}); diff --git a/packages/s3/src/__test__/utils.test.ts b/packages/s3/src/__test__/utils.test.ts new file mode 100644 index 000000000..7b5913d62 --- /dev/null +++ b/packages/s3/src/__test__/utils.test.ts @@ -0,0 +1,163 @@ +import type { ListObjectsOutput } from "@aws-sdk/client-s3"; + +import { Readable } from "node:stream"; +import { describe, expect, it } from "vitest"; + +import { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS } from "../constants"; +import { + convertStreamToBuffer, + getBaseName, + getFileExtension, + getFilenameWithSuffix, + getPreferredBucket, +} from "../utils"; + +describe("getBaseName", () => { + it("removes the file extension", () => { + expect(getBaseName("document.pdf")).toBe("document"); + }); + + it("removes only the last extension when multiple dots are present", () => { + expect(getBaseName("archive.tar.gz")).toBe("archive.tar"); + }); + + it("returns the filename unchanged when no extension is present", () => { + expect(getBaseName("Makefile")).toBe("Makefile"); + }); +}); + +describe("getFileExtension", () => { + it("returns the extension without the leading dot", () => { + expect(getFileExtension("document.pdf")).toBe("pdf"); + }); + + it("returns the last extension when multiple dots are present", () => { + expect(getFileExtension("archive.tar.gz")).toBe("gz"); + }); + + it("returns empty string when there is no extension", () => { + expect(getFileExtension("Makefile")).toBe(""); + }); + + it("returns the text after the dot for dotfiles", () => { + expect(getFileExtension(".env")).toBe("env"); + }); +}); + +describe("getPreferredBucket", () => { + it("returns optionsBucket when bucketChoice is BUCKET_FROM_OPTIONS and optionsBucket is set", () => { + expect( + getPreferredBucket("opts-bucket", "file-bucket", BUCKET_FROM_OPTIONS), + ).toBe("opts-bucket"); + }); + + it("returns fileFieldsBucket when bucketChoice is BUCKET_FROM_FILE_FIELDS and fileFieldsBucket is set", () => { + expect( + getPreferredBucket("opts-bucket", "file-bucket", BUCKET_FROM_FILE_FIELDS), + ).toBe("file-bucket"); + }); + + it("returns fileFieldsBucket when only fileFieldsBucket is provided (no optionsBucket)", () => { + expect(getPreferredBucket(undefined, "file-bucket")).toBe("file-bucket"); + }); + + it("returns optionsBucket when only optionsBucket is provided (no fileFieldsBucket)", () => { + expect(getPreferredBucket("opts-bucket")).toBe("opts-bucket"); + }); + + it("returns the shared value when both buckets are equal and no bucketChoice is set", () => { + expect(getPreferredBucket("same", "same")).toBe("same"); + }); + + it("returns fileFieldsBucket when both differ and no bucketChoice is set", () => { + expect(getPreferredBucket("opts-bucket", "file-bucket")).toBe( + "file-bucket", + ); + }); + + it("returns undefined when neither bucket is provided", () => { + expect(getPreferredBucket()).toBeUndefined(); + }); + + it("falls back to fileFieldsBucket when bucketChoice is BUCKET_FROM_OPTIONS but optionsBucket is not set", () => { + expect( + getPreferredBucket(undefined, "file-bucket", BUCKET_FROM_OPTIONS), + ).toBe("file-bucket"); + }); + + it("falls back to optionsBucket when bucketChoice is BUCKET_FROM_FILE_FIELDS but fileFieldsBucket is not set", () => { + expect( + getPreferredBucket("opts-bucket", undefined, BUCKET_FROM_FILE_FIELDS), + ).toBe("opts-bucket"); + }); +}); + +describe("getFilenameWithSuffix", () => { + it("returns filename with suffix -1 when no suffixed files exist in listing", () => { + const listing: ListObjectsOutput = { Contents: [] }; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-1.pdf", + ); + }); + + it("returns filename with the next available suffix when suffixed files already exist", () => { + const listing: ListObjectsOutput = { + Contents: [{ Key: "report-1.pdf" }, { Key: "report-2.pdf" }], + }; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-3.pdf", + ); + }); + + it("returns filename with suffix -1 when Contents is undefined", () => { + const listing: ListObjectsOutput = {}; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-1.pdf", + ); + }); + + it("ignores keys that do not match the base name pattern", () => { + const listing: ListObjectsOutput = { + Contents: [{ Key: "other-file.pdf" }, { Key: "report.pdf" }], + }; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-1.pdf", + ); + }); + + it("picks the highest existing numeric suffix and increments it", () => { + const listing: ListObjectsOutput = { + Contents: [ + { Key: "report-1.pdf" }, + { Key: "report-5.pdf" }, + { Key: "report-3.pdf" }, + ], + }; + expect(getFilenameWithSuffix(listing, "report", "pdf")).toBe( + "report-6.pdf", + ); + }); +}); + +describe("convertStreamToBuffer", () => { + it("converts a readable stream to a buffer containing all chunks", async () => { + const stream = Readable.from([Buffer.from("hello"), Buffer.from(" world")]); + const buffer = await convertStreamToBuffer(stream); + expect(buffer.toString()).toBe("hello world"); + }); + + it("returns an empty buffer for an empty stream", async () => { + const stream = Readable.from([]); + const buffer = await convertStreamToBuffer(stream); + expect(buffer.length).toBe(0); + }); + + it("rejects when the stream emits an error", async () => { + const stream = new Readable({ + read() { + this.emit("error", new Error("stream error")); + }, + }); + await expect(convertStreamToBuffer(stream)).rejects.toThrow("stream error"); + }); +}); diff --git a/packages/s3/src/constants.ts b/packages/s3/src/constants.ts index b6f437aa7..576f408a9 100644 --- a/packages/s3/src/constants.ts +++ b/packages/s3/src/constants.ts @@ -7,8 +7,8 @@ const ADD_SUFFIX = "add-suffix"; const ERROR = "error"; const ERROR_CODES = { - FILE_NOT_FOUND: "FILE_NOT_FOUND_ERROR", FILE_ALREADY_EXISTS_IN_S3: "FILE_ALREADY_EXISTS_IN_S3_ERROR", + FILE_NOT_FOUND: "FILE_NOT_FOUND_ERROR", }; export { diff --git a/packages/s3/src/index.ts b/packages/s3/src/index.ts index 6fb39ce08..0f9778399 100644 --- a/packages/s3/src/index.ts +++ b/packages/s3/src/index.ts @@ -1,7 +1,8 @@ -import type { S3Config } from "./types"; // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; +import type { S3Config } from "./types"; + declare module "@prefabs.tech/fastify-config" { interface ApiConfig { s3: S3Config; @@ -12,15 +13,15 @@ export * from "./constants"; export * from "./migrations/queries"; export { default as FileService } from "./model/files/service"; -export { default as S3Client } from "./utils/s3Client"; +export { default } from "./plugin"; +export { default as ajvFilePlugin } from "./plugins/ajvFile"; +export { default as multipartParserPlugin } from "./plugins/multipartParser"; export type { FilePayload, Multipart, S3Config } from "./types"; export type { File, FileCreateInput, FileUpdateInput } from "./types/file"; + +export { default as S3Client } from "./utils/s3Client"; +export type { S3ClientConfig } from "@aws-sdk/client-s3"; export type { FileUpload as GraphQLFileUpload, Upload as GraphQLUpload, } from "graphql-upload-minimal"; -export type { S3ClientConfig } from "@aws-sdk/client-s3"; - -export { default } from "./plugin"; -export { default as ajvFilePlugin } from "./plugins/ajvFile"; -export { default as multipartParserPlugin } from "./plugins/multipartParser"; diff --git a/packages/s3/src/migrations/queries.ts b/packages/s3/src/migrations/queries.ts index a3633cc39..e99601a05 100644 --- a/packages/s3/src/migrations/queries.ts +++ b/packages/s3/src/migrations/queries.ts @@ -1,10 +1,10 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { ZodTypeAny } from "zod"; + import { QuerySqlToken, sql } from "slonik"; import { TABLE_FILES } from "../constants"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; -import type { ZodTypeAny } from "zod"; - const createFilesTableQuery = ( config: ApiConfig, ): QuerySqlToken => { diff --git a/packages/s3/src/migrations/runMigrations.ts b/packages/s3/src/migrations/runMigrations.ts index acb9f8d7e..12669a266 100644 --- a/packages/s3/src/migrations/runMigrations.ts +++ b/packages/s3/src/migrations/runMigrations.ts @@ -1,8 +1,8 @@ -import { createFilesTableQuery } from "./queries"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import { createFilesTableQuery } from "./queries"; + const runMigrations = async (database: Database, config: ApiConfig) => { await database.connect(async (connection) => { await connection.query(createFilesTableQuery(config)); diff --git a/packages/s3/src/model/files/service.ts b/packages/s3/src/model/files/service.ts index 4a8480f63..c8ef5f8f4 100644 --- a/packages/s3/src/model/files/service.ts +++ b/packages/s3/src/model/files/service.ts @@ -2,28 +2,77 @@ import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { BaseService, formatDate } from "@prefabs.tech/fastify-slonik"; import { v4 as uuidv4 } from "uuid"; -import FileSqlFactory from "./sqlFactory"; +import type { + File, + FileCreateInput, + FilePayload, + FileUpdateInput, + PresignedUrlOptions, +} from "../../types"; + import { ADD_SUFFIX, ERROR, ERROR_CODES } from "../../constants"; import { - getPreferredBucket, + getBaseName, getFileExtension, getFilenameWithSuffix, - getBaseName, + getPreferredBucket, } from "../../utils"; import S3Client from "../../utils/s3Client"; - -import type { - PresignedUrlOptions, - File, - FilePayload, - FileCreateInput, - FileUpdateInput, -} from "../../types"; +import FileSqlFactory from "./sqlFactory"; class FileService extends BaseService { - protected _filename: string = undefined as unknown as string; + get fileExtension() { + return this._fileExtension; + } + set fileExtension(fileExtension: string) { + this._fileExtension = fileExtension; + } + get filename() { + if (this._filename && !this._filename.endsWith(this.fileExtension)) { + return `${this._filename}.${this.fileExtension}`; + } + + return this._filename || `${uuidv4()}.${this.fileExtension}`; + } + set filename(filename: string) { + this._filename = filename; + } + + get key() { + let formattedPath = ""; + + if (this.path) { + formattedPath = this.path.endsWith("/") ? this.path : this.path + "/"; + } + + return `${formattedPath}${this.filename}`; + } + + get path() { + return this._path; + } + + set path(path: string) { + this._path = path; + } + + get s3Client() { + return ( + this._s3Client ?? + (this._s3Client = new S3Client(this.config.s3.clientConfig)) + ); + } + + get sqlFactoryClass() { + return FileSqlFactory; + } + protected _fileExtension: string = undefined as unknown as string; + + protected _filename: string = undefined as unknown as string; + protected _path: string = undefined as unknown as string; + protected _s3Client: S3Client | undefined; async deleteFile(fileId: number, options?: { bucket?: string }) { @@ -63,8 +112,8 @@ class FileService extends BaseService { return { ...file, - mimeType: s3Object?.ContentType, fileStream: s3Object.Body, + mimeType: s3Object?.ContentType, }; } @@ -94,12 +143,12 @@ class FileService extends BaseService { async upload(data: FilePayload) { const { fileContent, fileFields } = data.file; - const { filename, mimetype, data: fileData } = fileContent; + const { data: fileData, filename, mimetype } = fileContent; const { - path = "", bucket = "", bucketChoice, filenameResolutionStrategy, + path = "", } = data.options || {}; const fileExtension = getFileExtension(filename); @@ -155,63 +204,14 @@ class FileService extends BaseService { ...(fileFields?.lastDownloadedAt && { lastDownloadedAt: formatDate(new Date(fileFields.lastDownloadedAt)), }), - originalFileName: filename, key: key, + originalFileName: filename, } as unknown as FileCreateInput; const result = this.create(fileInput); return result; } - - get fileExtension() { - return this._fileExtension; - } - - get filename() { - if (this._filename && !this._filename.endsWith(this.fileExtension)) { - return `${this._filename}.${this.fileExtension}`; - } - - return this._filename || `${uuidv4()}.${this.fileExtension}`; - } - - get key() { - let formattedPath = ""; - - if (this.path) { - formattedPath = this.path.endsWith("/") ? this.path : this.path + "/"; - } - - return `${formattedPath}${this.filename}`; - } - - get path() { - return this._path; - } - - get s3Client() { - return ( - this._s3Client ?? - (this._s3Client = new S3Client(this.config.s3.clientConfig)) - ); - } - - get sqlFactoryClass() { - return FileSqlFactory; - } - - set fileExtension(fileExtension: string) { - this._fileExtension = fileExtension; - } - - set filename(filename: string) { - this._filename = filename; - } - - set path(path: string) { - this._path = path; - } } export default FileService; diff --git a/packages/s3/src/plugin.ts b/packages/s3/src/plugin.ts index bf545e219..3f4f33f6d 100644 --- a/packages/s3/src/plugin.ts +++ b/packages/s3/src/plugin.ts @@ -1,11 +1,11 @@ +import type { FastifyInstance } from "fastify"; + import fastifyMultiPart from "@fastify/multipart"; import FastifyPlugin from "fastify-plugin"; import runMigrations from "./migrations/runMigrations"; import graphqlGQLUpload from "./plugins/graphqlUpload"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance) => { fastify.log.info("Registering fastify-s3 plugin"); @@ -16,19 +16,19 @@ const plugin = async (fastify: FastifyInstance) => { if (config.rest.enabled) { await fastify.register(fastifyMultiPart, { attachFieldsToBody: "keyValues", - sharedSchemaId: "fileSchema", limits: { fileSize: config.s3.fileSizeLimitInBytes || Number.POSITIVE_INFINITY, }, async onFile(part) { // @ts-expect-error: data value and data is missing in MultipartFile type part.value = { + data: await part.toBuffer(), + encoding: part.encoding, filename: part.filename, mimetype: part.mimetype, - encoding: part.encoding, - data: await part.toBuffer(), }; }, + sharedSchemaId: "fileSchema", }); } diff --git a/packages/s3/src/plugins/__test__/ajvFilePlugin.test.ts b/packages/s3/src/plugins/__test__/ajvFilePlugin.test.ts index 2b72cffbd..081b3ab4b 100644 --- a/packages/s3/src/plugins/__test__/ajvFilePlugin.test.ts +++ b/packages/s3/src/plugins/__test__/ajvFilePlugin.test.ts @@ -1,5 +1,5 @@ import Ajv from "ajv"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import plugin from "../ajvFile"; @@ -15,20 +15,20 @@ describe("ajvFile plugin", () => { plugin(ajv); const schema = { - type: "object", properties: { file: { isFile: true }, }, required: ["file"], + type: "object", }; const validate = ajv.compile(schema); const validData = { file: { + data: Buffer.from("test"), filename: "test.txt", mimetype: "text/plain", - data: Buffer.from("test"), }, }; const invalidData = { file: { name: "test.txt" } }; // Missing `filename` and `mimetype` @@ -51,14 +51,14 @@ describe("ajvFile plugin", () => { plugin(ajv); const schema = { - type: "object", properties: { files: { - type: "array", items: { isFile: true }, + type: "array", }, }, required: ["files"], + type: "object", }; const validate = ajv.compile(schema); @@ -66,14 +66,14 @@ describe("ajvFile plugin", () => { const validData = { files: [ { + data: Buffer.from("test"), filename: "test1.txt", mimetype: "text/plain", - data: Buffer.from("test"), }, { + data: Buffer.from("test"), filename: "test2.jpg", mimetype: "image/jpeg", - data: Buffer.from("test"), }, ], }; diff --git a/packages/s3/src/plugins/__test__/graphqlUpload.test.ts b/packages/s3/src/plugins/__test__/graphqlUpload.test.ts new file mode 100644 index 000000000..202c078f6 --- /dev/null +++ b/packages/s3/src/plugins/__test__/graphqlUpload.test.ts @@ -0,0 +1,60 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +describe("graphqlUpload plugin", () => { + let fastify: FastifyInstance; + + afterEach(async () => fastify.close()); + + it("does not modify request body when graphqlFileUploadMultipart is not set", async () => { + fastify = Fastify({ logger: false }); + const { default: plugin } = await import("../graphqlUpload"); + await fastify.register(plugin); + + let capturedBody: unknown; + fastify.post("/test", async (req) => { + capturedBody = req.body; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ original: true }), + url: "/test", + }); + + expect(capturedBody).toEqual({ original: true }); + }); + + it("does not modify request body when graphqlFileUploadMultipart is explicitly false", async () => { + fastify = Fastify({ logger: false }); + const { default: plugin } = await import("../graphqlUpload"); + await fastify.register(plugin); + + fastify.addHook("onRequest", async (req) => { + req.graphqlFileUploadMultipart = false; + }); + + let capturedBody: unknown; + fastify.post("/test", async (req) => { + capturedBody = req.body; + return {}; + }); + + await fastify.ready(); + + await fastify.inject({ + headers: { "content-type": "application/json" }, + method: "POST", + payload: JSON.stringify({ original: true }), + url: "/test", + }); + + expect(capturedBody).toEqual({ original: true }); + }); +}); diff --git a/packages/s3/src/plugins/ajvFile.ts b/packages/s3/src/plugins/ajvFile.ts index 69ace8600..69bb5deaa 100644 --- a/packages/s3/src/plugins/ajvFile.ts +++ b/packages/s3/src/plugins/ajvFile.ts @@ -17,7 +17,6 @@ const validateFile = (data: unknown): boolean => { export default function plugin(ajv: Ajv): Ajv { return ajv.addKeyword({ - keyword: "isFile", compile: (_schema: boolean, parentSchema: AnySchemaObject) => { const schema = parentSchema; if (schema.type === "array") { @@ -40,5 +39,6 @@ export default function plugin(ajv: Ajv): Ajv { error: { message: "should be a file or array of files", }, + keyword: "isFile", }); } diff --git a/packages/s3/src/plugins/graphqlUpload.ts b/packages/s3/src/plugins/graphqlUpload.ts index e2a62c014..65fda6000 100644 --- a/packages/s3/src/plugins/graphqlUpload.ts +++ b/packages/s3/src/plugins/graphqlUpload.ts @@ -1,8 +1,8 @@ +import type { FastifyPluginCallback } from "fastify"; + import fastifyPlugin from "fastify-plugin"; import { processRequest, UploadOptions } from "graphql-upload-minimal"; -import type { FastifyPluginCallback } from "fastify"; - declare module "fastify" { interface FastifyRequest { graphqlFileUploadMultipart?: boolean; diff --git a/packages/s3/src/plugins/multipartParser.ts b/packages/s3/src/plugins/multipartParser.ts index 9ad790cea..2b45712d9 100644 --- a/packages/s3/src/plugins/multipartParser.ts +++ b/packages/s3/src/plugins/multipartParser.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance } from "fastify"; + import fastifyPlugin from "fastify-plugin"; import { processMultipartFormData } from "../utils"; -import type { FastifyInstance } from "fastify"; - declare module "fastify" { interface FastifyRequest { graphqlFileUploadMultipart?: boolean; diff --git a/packages/s3/src/types/file.ts b/packages/s3/src/types/file.ts index 2ef0ca8a4..1a639ad7f 100644 --- a/packages/s3/src/types/file.ts +++ b/packages/s3/src/types/file.ts @@ -1,22 +1,22 @@ interface File { - id: number; - originalFileName: string; bucket?: string; + createdAt: number; description?: string; - key: string; - uploadedById?: string; - uploadedAt: number; downloadCount?: number; + id: number; + key: string; lastDownloadedAt?: number; - createdAt: number; + originalFileName: string; updatedAt: number; + uploadedAt: number; + uploadedById?: string; } type FileCreateInput = Omit< File, - "id" | "originalFileName" | "key" | "createdAt" | "updatedAt" + "createdAt" | "id" | "key" | "originalFileName" | "updatedAt" >; -type FileUpdateInput = Partial>; +type FileUpdateInput = Partial>; export type { File, FileCreateInput, FileUpdateInput }; diff --git a/packages/s3/src/types/index.ts b/packages/s3/src/types/index.ts index e6b5481aa..f17f9b714 100644 --- a/packages/s3/src/types/index.ts +++ b/packages/s3/src/types/index.ts @@ -1,5 +1,9 @@ +import type { S3ClientConfig } from "@aws-sdk/client-s3"; + import { ReadStream } from "node:fs"; +import type { FileCreateInput } from "./file"; + import { ADD_SUFFIX, BUCKET_FROM_FILE_FIELDS, @@ -8,29 +12,16 @@ import { OVERWRITE, } from "../constants"; -import type { FileCreateInput } from "./file"; -import type { S3ClientConfig } from "@aws-sdk/client-s3"; - +interface BaseOption { + bucket?: string; +} type BucketChoice = typeof BUCKET_FROM_FILE_FIELDS | typeof BUCKET_FROM_OPTIONS; + type FilenameResolutionStrategy = | typeof ADD_SUFFIX | typeof ERROR | typeof OVERWRITE; -interface BaseOption { - bucket?: string; -} - -interface PresignedUrlOptions extends BaseOption { - signedUrlExpiresInSecond?: number; -} - -interface FilePayloadOptions extends BaseOption { - bucketChoice?: BucketChoice; - filenameResolutionStrategy?: FilenameResolutionStrategy; - path?: string; -} - interface FilePayload { file: { fileContent: Multipart; @@ -39,18 +30,28 @@ interface FilePayload { options?: FilePayloadOptions; } +interface FilePayloadOptions extends BaseOption { + bucketChoice?: BucketChoice; + filenameResolutionStrategy?: FilenameResolutionStrategy; + path?: string; +} + interface Multipart { data: Buffer | ReadStream; - filename: string; encoding?: string; - mimetype: string; + filename: string; limit?: boolean; + mimetype: string; +} + +interface PresignedUrlOptions extends BaseOption { + signedUrlExpiresInSecond?: number; } interface S3Config { - bucket: string | Record; + bucket: Record | string; clientConfig: S3ClientConfig; - fileSizeLimitInBytes?: number; filenameResolutionStrategy?: FilenameResolutionStrategy; + fileSizeLimitInBytes?: number; table?: { name?: string; }; @@ -58,11 +59,11 @@ interface S3Config { export type { BucketChoice, - PresignedUrlOptions, + FilenameResolutionStrategy, FilePayload, FilePayloadOptions, - FilenameResolutionStrategy, Multipart, + PresignedUrlOptions, S3Config, }; diff --git a/packages/s3/src/utils/index.ts b/packages/s3/src/utils/index.ts index 27473f2f3..6de1febae 100644 --- a/packages/s3/src/utils/index.ts +++ b/packages/s3/src/utils/index.ts @@ -1,14 +1,14 @@ +import type { ListObjectsOutput } from "@aws-sdk/client-s3"; +import type { FastifyRequest } from "fastify"; + +import Busboy, { FileInfo } from "busboy"; import { IncomingMessage } from "node:http"; import { Readable } from "node:stream"; -import Busboy, { FileInfo } from "busboy"; +import type { BucketChoice, Multipart } from "../types"; import { BUCKET_FROM_FILE_FIELDS, BUCKET_FROM_OPTIONS } from "../constants"; -import type { BucketChoice, Multipart } from "../types"; -import type { ListObjectsOutput } from "@aws-sdk/client-s3"; -import type { FastifyRequest } from "fastify"; - const convertStreamToBuffer = async (stream: Readable): Promise => { return new Promise((resolve, reject) => { const chunks: Uint8Array[] = []; @@ -124,8 +124,8 @@ const processMultipartFormData = ( files[fieldName].push({ ...fileInfo, - mimetype: fileInfo.mimeType, data: fileBuffer, + mimetype: fileInfo.mimeType, }); }); }, @@ -152,7 +152,7 @@ export { convertStreamToBuffer, getBaseName, getFileExtension, - getPreferredBucket, getFilenameWithSuffix, + getPreferredBucket, processMultipartFormData, }; diff --git a/packages/s3/src/utils/s3Client.ts b/packages/s3/src/utils/s3Client.ts index 31bde376f..da6efe137 100644 --- a/packages/s3/src/utils/s3Client.ts +++ b/packages/s3/src/utils/s3Client.ts @@ -1,29 +1,40 @@ -import { ReadStream } from "node:fs"; -import { Readable } from "node:stream"; +import type { + AbortMultipartUploadCommandOutput, + CompleteMultipartUploadCommandOutput, + DeleteObjectCommandOutput, + ListObjectsCommandOutput, + S3ClientConfig, +} from "@aws-sdk/client-s3"; import { - S3Client, - GetObjectCommand, DeleteObjectCommand, + GetObjectCommand, HeadObjectCommand, ListObjectsCommand, + S3Client, } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { ReadStream } from "node:fs"; +import { Readable } from "node:stream"; import { convertStreamToBuffer } from "."; -import type { - AbortMultipartUploadCommandOutput, - CompleteMultipartUploadCommandOutput, - DeleteObjectCommandOutput, - ListObjectsCommandOutput, - S3ClientConfig, -} from "@aws-sdk/client-s3"; - class s3Client { + get bucket() { + return this._bucket; + } + set bucket(bucket: string) { + this._bucket = bucket; + } + get config() { + return this._config; + } + protected _bucket: string = undefined as unknown as string; + protected _config: S3ClientConfig; + protected _storageClient: S3Client; constructor(config: S3ClientConfig) { @@ -31,18 +42,6 @@ class s3Client { this._storageClient = this.init(); } - get config() { - return this._config; - } - - get bucket() { - return this._bucket; - } - - set bucket(bucket: string) { - this._bucket = bucket; - } - /** * Deletes an object from the Amazon S3 bucket. * @param {string} filePath - The path of the object to delete in the S3 bucket. @@ -100,37 +99,24 @@ class s3Client { const streamValue = await convertStreamToBuffer(stream); return { - ContentType: response.ContentType, Body: streamValue, + ContentType: response.ContentType, }; } /** - * Uploads a file to the specified S3 bucket. + * Retrieves a list of objects from the S3 bucket with a specified prefix. * - * @param {Buffer} fileStream - The file content as a Buffer. - * @param {string} key - The key (file name) to use when storing the file in the bucket. - * @param {string} mimetype - The MIME type of the file. - * @returns {Promise} A Promise that resolves with information about the uploaded object. + * @param {string} baseName - The prefix used to filter objects within the S3 bucket. + * @returns {Promise} A Promise that resolves to the result of the list operation. */ - public async upload( - fileStream: Buffer | ReadStream, - key: string, - mimetype: string, - ): Promise< - AbortMultipartUploadCommandOutput | CompleteMultipartUploadCommandOutput - > { - const putCommand = new Upload({ - client: this._storageClient, - params: { + public async getObjects(baseName: string): Promise { + return await this._storageClient.send( + new ListObjectsCommand({ Bucket: this.bucket, - Key: key, - Body: fileStream, - ContentType: mimetype, - }, - }); - - return await putCommand.done(); + Prefix: baseName, + }), + ); } /** @@ -159,18 +145,31 @@ class s3Client { } /** - * Retrieves a list of objects from the S3 bucket with a specified prefix. + * Uploads a file to the specified S3 bucket. * - * @param {string} baseName - The prefix used to filter objects within the S3 bucket. - * @returns {Promise} A Promise that resolves to the result of the list operation. + * @param {Buffer} fileStream - The file content as a Buffer. + * @param {string} key - The key (file name) to use when storing the file in the bucket. + * @param {string} mimetype - The MIME type of the file. + * @returns {Promise} A Promise that resolves with information about the uploaded object. */ - public async getObjects(baseName: string): Promise { - return await this._storageClient.send( - new ListObjectsCommand({ + public async upload( + fileStream: Buffer | ReadStream, + key: string, + mimetype: string, + ): Promise< + AbortMultipartUploadCommandOutput | CompleteMultipartUploadCommandOutput + > { + const putCommand = new Upload({ + client: this._storageClient, + params: { + Body: fileStream, Bucket: this.bucket, - Prefix: baseName, - }), - ); + ContentType: mimetype, + Key: key, + }, + }); + + return await putCommand.done(); } protected init(): S3Client { diff --git a/packages/s3/vite.config.ts b/packages/s3/vite.config.ts index 2d63986aa..701cda0df 100644 --- a/packages/s3/vite.config.ts +++ b/packages/s3/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -29,21 +28,21 @@ export default defineConfig(({ mode }) => { "@aws-sdk/client-s3": "AWSClientS3", "@aws-sdk/lib-storage": "AWSLibStorage", "@aws-sdk/s3-request-presigner": "AWSS3RequestPresigner", + "@fastify/cors": "FastifyCors", + "@fastify/formbody": "FastifyFormbody", + "@fastify/multipart": "FastifyMultipart", "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", "@prefabs.tech/fastify-error-handler": "PrefabsTechFastifyErrorHandler", "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", - "@fastify/cors": "FastifyCors", - "@fastify/formbody": "FastifyFormbody", - "@fastify/multipart": "FastifyMultipart", busboy: "Busboy", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", "graphql-upload-minimal": "graphqlUploadMinimal", slonik: "Slonik", - zod: "zod", uuid: "uuid", + zod: "zod", }, }, }, diff --git a/packages/slonik/FEATURES.md b/packages/slonik/FEATURES.md new file mode 100644 index 000000000..6c2a041b8 --- /dev/null +++ b/packages/slonik/FEATURES.md @@ -0,0 +1,187 @@ + + +## Plugin Registration + +1. **Main plugin (default export)** — Registers as a Fastify 5 plugin (via `fastify-plugin`). Accepts `SlonikOptions` directly. When called with no options, falls back to `fastify.config.slonik` and logs a deprecation warning. Throws a descriptive error if neither source provides configuration. + +2. **Idempotent decorator registration** — Checks `fastify.hasDecorator` and `fastify.hasRequestDecorator` before decorating, so the internal `fastifySlonik` plugin can be registered multiple times without conflict. + +3. **Connection verification on startup** — After creating the pool, calls `pool.connect()` to verify the database is reachable. Logs success (`"Connected to Postgres DB"`) or error (`"Error happened while connecting to Postgres DB"`) and rethrows on failure. + +4. **Auto-provisioned PostgreSQL extensions** — On startup, runs `CREATE EXTENSION IF NOT EXISTS` for `citext` and `unaccent` by default. Merges and deduplicates with any extensions listed in `options.extensions`. + +5. **`migrationPlugin`** — Standalone Fastify plugin that runs SQL file migrations via `@prefabs.tech/postgres-migrations`. Applies the same config-fallback logic (direct options or `fastify.config.slonik`). Default migration directory is `"migrations"`. + +## Fastify Decorators + +6. **`fastify.slonik`** — Decorates the Fastify instance with a `Database` object `{ pool, connect, query }` wrapping the Slonik `DatabasePool`. + +7. **`fastify.sql`** — Decorates the Fastify instance with slonik's `sql` tagged-template helper. + +8. **`request.slonik`** — Decorates every `FastifyRequest` with the same `Database` object, populated via an `onRequest` hook. + +9. **`request.sql`** — Decorates every `FastifyRequest` with the `sql` helper, populated via the same `onRequest` hook. + +10. **`request.dbSchema`** — Decorates every `FastifyRequest` with an empty string (`""`). Consuming code sets this to support per-request schema routing. + +## Module Augmentation + +11. **`FastifyInstance` augmentation** — Extends `fastify`'s `FastifyInstance` interface with `slonik: Database` and `sql: typeof sql`. + +12. **`FastifyRequest` augmentation** — Extends `fastify`'s `FastifyRequest` interface with `slonik: Database`, `sql: typeof sql`, and `dbSchema: string`. + +13. **`ApiConfig` augmentation** — Extends `@prefabs.tech/fastify-config`'s `ApiConfig` interface with `slonik: SlonikConfig`. + +## Client Configuration + +14. **`createClientConfiguration` factory** — Builds a `ClientConfigurationInput` with opinionated defaults: `captureStackTrace: false`, `connectionRetryLimit: 3`, `connectionTimeout: 5000 ms`, `idleInTransactionSessionTimeout: 60000 ms`, `idleTimeout: 5000 ms`, `maximumPoolSize: 10`, `queryRetryLimit: 5`, `statementTimeout: 60000 ms`, `transactionRetryLimit: 5`. Caller-supplied `config` is shallow-merged on top. + +15. **Built-in interceptor chain** — `fieldNameCaseConverter` (snake_case → camelCase via `humps.camelizeKeys`) and `resultParser` (Zod row validation) are always prepended to the interceptor list, before any optional or user-supplied interceptors. + +16. **Optional query-logging interceptor** — Added to the chain (after built-in interceptors) when `options.queryLogging.enabled === true`. Uses `slonik-interceptor-query-logging`. Requires `ROARR_LOG=true` at runtime. + +17. **User interceptors merged** — Interceptors in `clientConfiguration.interceptors` are appended after built-in and logging interceptors. + +18. **Extended type parsers** — `createTypeParserPreset()` (slonik built-ins) plus `createBigintTypeParser()` (`int8` → `Number.parseInt`) are registered on every pool. + +## Field Name Conversion + +19. **Automatic snake_case → camelCase** — The `fieldNameCaseConverter` interceptor calls `humps.camelizeKeys()` on every query result row, so DB columns like `created_at` become `createdAt` in application code. + +## Result Validation + +20. **Zod row validation** — The `resultParser` interceptor validates each row against the Zod schema passed to `sql.type(...)`. Throws `SchemaValidationError` on failure. Passes rows through unchanged when no schema is attached to the query. + +## Type Parsers + +21. **`createBigintTypeParser`** — Exported factory returning a `DriverTypeParser` that maps the `int8` OID to `Number.parseInt`, preventing PostgreSQL `bigint` columns from being returned as strings. + +## Database Creation + +22. **`createDatabase` utility** — Exported function that creates a Slonik pool and wraps it in the `Database` interface `{ pool, connect, query }`. + +## SQL Fragment Helpers + +23. **`createFilterFragment`** — Builds a `FragmentSqlToken` from a `FilterInput`; returns an empty fragment when `filters` is `undefined`. + +24. **`createLimitFragment`** — Builds `LIMIT n` or `LIMIT n OFFSET m` as a `FragmentSqlToken`. + +25. **`createSortFragment`** — Builds `ORDER BY col [ASC|DESC] [, ...]` from a `SortInput[]`. Supports dot-notation keys, camelCase-to-snake_case conversion, and `unaccent(lower(...))` for accent/case-insensitive sorting. + +26. **`createTableFragment`** — Builds a `FragmentSqlToken` referencing `schema.table` or just `table`. + +27. **`createTableIdentifier`** — Builds an `IdentifierSqlToken` for `[schema, table]` or `[table]`. + +28. **`createWhereFragment`** — Merges a `FilterInput`, additional `FragmentSqlToken[]`, and an optional `IdentifierSqlToken` into a single `WHERE … AND …` fragment, or an empty fragment when nothing applies. Strips any leading `WHERE` keyword from provided fragments to avoid duplication. + +29. **`createWhereIdFragment`** — Builds a `WHERE id = $1` fragment. + +30. **`isValueExpression`** — Type-guard that returns `true` for values usable as a Slonik `ValueExpression` (`null`, `string`, `number`, `boolean`, `Date`, `Buffer`, or a uniform array thereof). + +## Filter System + +31. **Filter operators** — `eq` (equals), `ct` (ILIKE `%value%`), `sw` (ILIKE `value%`), `ew` (ILIKE `%value`), `gt` (`>`), `gte` (`>=`), `lt` (`<`), `lte` (`<=`), `in` (comma-separated list), `bt` (BETWEEN, comma-separated bounds), `dwithin` (PostGIS geography radius, `"lat,lng,radius_m"`). + +32. **`not` flag** — Adding `not: true` (or `"true"` / `"1"`) to any filter negates the condition (e.g., `!=`, `NOT IN`, `NOT BETWEEN`, `IS NOT NULL`). + +33. **`insensitive` flag** — Adding `insensitive: true` wraps both field and value in `unaccent(lower(...))` for accent- and case-insensitive comparisons. Works with `eq`, `ct`, `sw`, `ew`, `gt`, `gte`, `lt`, `lte`, `in`, `bt`. + +34. **NULL check via `value: "null"`** — When `operator: "eq"` and `value` is `"null"` or `"NULL"`, generates `IS NULL` or `IS NOT NULL` (with `not: true`). + +35. **`in` operator validation** — Throws `Error("IN operator requires at least one value")` if the comma-separated list is empty. + +36. **`bt` (between) operator validation** — Throws `Error("BETWEEN operator requires exactly two values")` if either bound is missing. + +37. **Recursive AND/OR composition** — `FilterInput` can be `{ AND: FilterInput[] }` or `{ OR: FilterInput[] }` at any nesting depth. Empty arrays produce an empty fragment (no condition). + +38. **`applyFiltersToQuery`** — Wraps `buildFilterFragment` output in a `WHERE` clause, or returns an empty fragment if there are no conditions. + +## DefaultSqlFactory + +39. **`DefaultSqlFactory` class** — Concrete `SqlFactory` implementation. Requires a static `TABLE` name on the subclass. Constructor accepts `config: ApiConfig`, `database: Database`, and optional `schema` string (defaults to `"public"`). + +40. **Static defaults** — `LIMIT_DEFAULT = 20`, `LIMIT_MAX = 50`, `SORT_DIRECTION = "ASC"`, `SORT_KEY = "id"`. + +41. **Config-driven pagination** — `limitDefault` and `limitMax` are read from `config.slonik.pagination.defaultLimit` / `maxLimit` when present, falling back to static defaults. + +42. **Soft-delete support** — Protected `_softDeleteEnabled = false`. When set to `true`, `getDeleteSql` issues `UPDATE … SET deleted_at = NOW()` instead of `DELETE` (unless `force = true`). All read queries automatically append `deleted_at IS NULL`. + +43. **Zod validation schema** — `validationSchema` property (default `z.any()`). All generated queries use `sql.type(validationSchema)` so rows are parsed by Zod on return. + +44. **camelCase → snake_case column mapping** — `getCreateSql` and `getUpdateSql` call `humps.decamelize` on every key, so callers pass camelCase field names. + +45. **`getAllSql`** — Generates `SELECT FROM [WHERE …] [ORDER BY …]`. Narrows the Zod schema to requested fields when the factory schema is a `ZodObject`. + +46. **`getCountSql`** — Generates `SELECT COUNT(*) FROM
[WHERE …]` validated with `z.object({ count: z.number() })`. + +47. **`getCreateSql`** — Generates `INSERT INTO
(…) VALUES (…) RETURNING *`. + +48. **`getDeleteSql`** — Generates `DELETE FROM
WHERE id = $1 RETURNING *` (or soft-delete UPDATE when enabled). + +49. **`getFindByIdSql`** — Generates `SELECT * FROM
WHERE id = $1`. + +50. **`getFindOneSql`** — Generates `SELECT * FROM
[WHERE …] [ORDER BY …] LIMIT 1`. + +51. **`getFindSql`** — Generates `SELECT * FROM
[WHERE …] [ORDER BY …]`. + +52. **`getListSql`** — Generates `SELECT * FROM
[WHERE …] [ORDER BY …] LIMIT n [OFFSET m]`. Limit is clamped to `limitMax`. + +53. **`getUpdateSql`** — Generates `UPDATE
SET col = $1 [, …] WHERE id = $n RETURNING *`. + +54. **`getAdditionalFilterFragments` hook** — Protected method returning `[]` by default; subclasses override to inject extra WHERE conditions into every read query. + +55. **`getTableFragment` (deprecated)** — Returns `this.tableFragment`. Use the `tableFragment` getter directly. + +## BaseService + +56. **`BaseService` abstract class** — Generic `BaseService` implementing `Service`. Constructor accepts `config: ApiConfig`, `database: Database`, and optional `schema` string (defaults to `"public"`). + +57. **Lazy factory instantiation** — The `SqlFactory` instance is created on first access of `this.factory`. Subclasses override `get sqlFactoryClass()` to supply a custom factory. + +58. **`all(fields, sort?)`** — Fetches all rows with a restricted field set. + +59. **`count(filters?)`** — Returns total row count, optionally filtered. + +60. **`create(data)`** — Inserts one row and returns the created entity, or `undefined` if the DB returns nothing. + +61. **`delete(id, force?)`** — Deletes (or soft-deletes) by `id`. Returns the deleted entity or `null`. + +62. **`find(filters?, sort?)`** — Returns all rows matching optional filters and sort. + +63. **`findById(id)`** — Returns one row by primary key or `null`. + +64. **`findOne(filters?, sort?)`** — Returns the first matching row or `null`. + +65. **`list(limit?, offset?, filters?, sort?)`** — Returns `PaginatedList` with `{ data, totalCount, filteredCount }`. Total count and filtered count are fetched concurrently with the data query. + +66. **`update(id, data)`** — Updates a row by `id` and returns the updated entity. + +67. **Pre/post lifecycle hooks** — Optional `pre` / `post` protected methods (e.g., `preCreate`, `postCreate`) are called before and after every DB operation. Subclasses override to transform input data or output results. Hook results are validated for type compatibility before being applied. + +## Standalone Migration Utility + +68. **`migrate` utility** — Builds a `pg.Client` from `SlonikOptions.db` (includes SSL from `clientConfiguration.ssl` when set), runs `@prefabs.tech/postgres-migrations`, then disconnects. Default migrations path is `"migrations"`. + +## Type Exports + +69. **`SlonikConfig` / `SlonikOptions`** — Plugin configuration: `db` (required `ConnectionOptions`), optional `clientConfiguration`, `extensions`, `migrations.path`, `pagination.defaultLimit`/`maxLimit`, `queryLogging.enabled`. + +70. **`Database`** — `{ pool: DatabasePool; connect; query }` — the shape decorated onto the Fastify instance and requests. + +71. **`BaseFilterInput`** — Single filter condition shape: `key`, `operator`, `value`, optional `not`, `insensitive`. + +72. **`FilterInput`** — Recursive union: `BaseFilterInput | { AND: FilterInput[] } | { OR: FilterInput[] }`. + +73. **`SortInput`** — `{ key: string; direction: SortDirection; insensitive?: boolean | string }`. + +74. **`SortDirection`** — `"ASC" | "DESC"`. + +75. **`PaginatedList`** — `{ data: readonly T[]; totalCount: number; filteredCount: number }`. + +76. **`Service`** — Interface contract for service classes. + +77. **`SqlFactory`** — Interface contract for SQL factory classes. + +## Utility Exports + +78. **`formatDate(date)`** — Formats a `Date` as `"YYYY-MM-DD HH:mm:ss.SSS"` (ISO string truncated to 23 chars, `T` replaced with a space) — suitable for PostgreSQL timestamp columns. diff --git a/packages/slonik/GUIDE.md b/packages/slonik/GUIDE.md new file mode 100644 index 000000000..4cf7c9332 --- /dev/null +++ b/packages/slonik/GUIDE.md @@ -0,0 +1,930 @@ +# @prefabs.tech/fastify-slonik — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-slonik slonik fastify fastify-plugin zod + +# pnpm +pnpm add @prefabs.tech/fastify-slonik slonik fastify fastify-plugin zod +``` + +Peer dependencies that are always required: + +| Peer | Version | +| ------------------------------ | ---------- | +| `fastify` | `>=5.2.1` | +| `fastify-plugin` | `>=5.0.1` | +| `slonik` | `>=46.1.0` | +| `zod` | `>=3.23.8` | +| `@prefabs.tech/fastify-config` | `0.93.5` | + +`pg-mem` is an optional peer — only needed for in-memory testing: + +```bash +pnpm add -D pg-mem +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# from the repo root +pnpm install + +# run tests for this package only +pnpm --filter @prefabs.tech/fastify-slonik test + +# build +pnpm --filter @prefabs.tech/fastify-slonik build +``` + +--- + +## Setup + +Register the plugin once during application bootstrap. All later examples assume this setup is in place. + +```typescript +import Fastify from "fastify"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(slonikPlugin, { + db: { + host: "localhost", + port: 5432, + databaseName: "mydb", + username: "app", + password: "secret", + }, + // optional: run SQL migrations on startup + migrations: { + path: "migrations", // default: "migrations" + }, + // optional: add extra PostgreSQL extensions (citext + unaccent are always added) + extensions: ["postgis"], + // optional: enable query logging (requires ROARR_LOG=true at runtime) + queryLogging: { + enabled: process.env.NODE_ENV !== "production", + }, + // optional: override pagination defaults + pagination: { + defaultLimit: 25, + maxLimit: 100, + }, + // optional: override any slonik ClientConfigurationInput defaults + clientConfiguration: { + maximumPoolSize: 20, + }, +}); + +await fastify.listen({ port: 3000 }); +``` + +After registration the following are available everywhere in your application: + +| Symbol | Available on | +| ------------------ | ---------------------- | +| `fastify.slonik` | Fastify instance | +| `fastify.sql` | Fastify instance | +| `request.slonik` | Every `FastifyRequest` | +| `request.sql` | Every `FastifyRequest` | +| `request.dbSchema` | Every `FastifyRequest` | + +--- + +## Base Libraries + +### slonik — Partial Passthrough + +→ Their docs: [https://www.npmjs.com/package/slonik](https://www.npmjs.com/package/slonik) + +slonik provides the type-safe PostgreSQL client (`createPool`, `sql`, `DatabasePool`, `ConnectionRoutine`, etc.). This package does **not** re-export slonik's pool directly — it wraps it in the `Database` interface and surfaces it via Fastify decorators. You interact with the pool through `fastify.slonik.pool`, `fastify.slonik.connect(...)`, and `fastify.slonik.query(...)`. + +What we add on top: + +- Opinionated `ClientConfigurationInput` defaults (see [Client Configuration defaults](#feature-14-createclientconfiguration-factory)). +- Two always-active interceptors: snake_case → camelCase conversion and Zod row validation. +- Auto-provisioned PostgreSQL extensions on startup. +- The `Database` wrapper type exported from this package. + +### fastify-plugin — Full Passthrough + +→ Their docs: [https://www.npmjs.com/package/fastify-plugin](https://www.npmjs.com/package/fastify-plugin) + +Used internally to register our plugins without encapsulation (so decorators are visible to the parent scope). You do not interact with `fastify-plugin` directly. + +### @prefabs.tech/fastify-config — Partial Passthrough + +→ Their docs: internal monorepo package (`packages/config`). + +We augment the `ApiConfig` interface from this package with a `slonik: SlonikConfig` property. This makes `fastify.config.slonik` the fallback config source when no options are passed to the plugin directly. + +### @prefabs.tech/postgres-migrations — Full Passthrough + +→ Their docs: [https://www.npmjs.com/package/@prefabs.tech/postgres-migrations](https://www.npmjs.com/package/@prefabs.tech/postgres-migrations) + +Used internally by `migrate.ts` and `migrationPlugin`. You never call it directly — the migration plugin and the `migrate` utility function handle it. + +### humps — Full Passthrough + +→ Their docs: [https://www.npmjs.com/package/humps](https://www.npmjs.com/package/humps) + +Used internally by the `fieldNameCaseConverter` interceptor and by `DefaultSqlFactory` for camelCase ↔ snake_case column name mapping. Not re-exported. + +### slonik-interceptor-query-logging — Full Passthrough + +→ Their docs: [https://www.npmjs.com/package/slonik-interceptor-query-logging](https://www.npmjs.com/package/slonik-interceptor-query-logging) + +Used internally when `queryLogging.enabled === true`. Not re-exported. + +### zod — Partial Passthrough + +→ Their docs: [https://zod.dev](https://zod.dev) + +Used internally by `resultParser` and `DefaultSqlFactory`. You supply Zod schemas to your `DefaultSqlFactory` subclass via `_validationSchema`. Zod itself is not re-exported from this package. + +--- + +## Features + +### Feature 1 — Main plugin registration + +Register `slonikPlugin` with direct options or let it fall back to `fastify.config.slonik`. + +```typescript +// Direct options (recommended) +await fastify.register(slonikPlugin, { db: { host: "localhost", ... } }); + +// Fallback to fastify-config (deprecated path — logs a warning) +// fastify.config.slonik must be set by @prefabs.tech/fastify-config +await fastify.register(slonikPlugin); +``` + +If neither source provides configuration the plugin throws: + +``` +Error: Missing slonik configuration. Did you forget to pass it to the slonik plugin? +``` + +### Feature 2 — Idempotent decorator registration + +The internal `fastifySlonik` plugin guards all `decorate` / `decorateRequest` calls with `hasDecorator` checks, so registering the plugin multiple times (e.g., in multiple scopes) will not throw a duplicate-decorator error. + +### Feature 3 — Connection verification on startup + +Immediately after pool creation the plugin opens a test connection: + +```typescript +// No code needed — happens automatically on registration. +// On success: fastify.log.info("✅ Connected to Postgres DB") +// On failure: fastify.log.error("🔴 Error happened while connecting to Postgres DB") +// + the error is rethrown +``` + +### Feature 4 — Auto-provisioned PostgreSQL extensions + +On every startup, before your route handlers run, the plugin runs: + +```sql +CREATE EXTENSION IF NOT EXISTS "citext"; +CREATE EXTENSION IF NOT EXISTS "unaccent"; +-- plus any extras from options.extensions +``` + +```typescript +await fastify.register(slonikPlugin, { + db: { ... }, + extensions: ["postgis", "uuid-ossp"], // merged with the defaults; duplicates removed +}); +``` + +### Feature 5 — migrationPlugin + +A standalone Fastify plugin that runs SQL file migrations on startup using `@prefabs.tech/postgres-migrations`. + +```typescript +import { migrationPlugin } from "@prefabs.tech/fastify-slonik"; + +// Register before the main plugin if you want migrations to run first +await fastify.register(migrationPlugin, { + db: { + host: "localhost", + port: 5432, + databaseName: "mydb", + username: "app", + password: "secret", + }, + migrations: { path: "db/migrations" }, // default: "migrations" +}); +``` + +### Feature 6–9 — Fastify instance and request decorators + +```typescript +fastify.get("/users", async (req, reply) => { + // Both the instance and the request have slonik + sql + const rows = await req.slonik.connect((conn) => + conn.any(req.sql`SELECT * FROM users`), + ); + + // Or use the instance-level decorator in plugins/hooks + const count = await fastify.slonik.query( + fastify.sql`SELECT COUNT(*) FROM users`, + ); + + return rows; +}); +``` + +### Feature 10 — `request.dbSchema` + +An empty string set on each request. Useful for multi-tenant applications where each request should query a different PostgreSQL schema. + +```typescript +fastify.addHook("onRequest", async (req) => { + req.dbSchema = getTenantSchema(req.headers["x-tenant-id"] as string); +}); + +fastify.get("/items", async (req) => { + const factory = new ItemSqlFactory(fastify.config, req.slonik, req.dbSchema); + // factory now queries `.items` +}); +``` + +### Feature 11–13 — Module augmentation + +The package extends three external interfaces so TypeScript knows about the added properties without any additional type imports. + +```typescript +import type { FastifyInstance, FastifyRequest } from "fastify"; + +// FastifyInstance.slonik / .sql are typed automatically +// FastifyRequest.slonik / .sql / .dbSchema are typed automatically +// ApiConfig.slonik is typed automatically + +// No extra imports needed in consuming code +const pool = fastify.slonik.pool; // DatabasePool +const tag = fastify.sql; // typeof sql +const schema = request.dbSchema; // string +``` + +### Feature 14 — `createClientConfiguration` factory + +Creates a `ClientConfigurationInput` with safe production defaults. You can override any field via `options.clientConfiguration`. + +```typescript +import { createDatabase } from "@prefabs.tech/fastify-slonik"; + +// createDatabase calls createClientConfiguration internally +const db = await createDatabase("postgres://localhost/mydb", { + maximumPoolSize: 5, // overrides default of 10 + connectionTimeout: 10_000, // overrides default of 5000 ms +}); +``` + +Default values applied when not overridden: + +| Setting | Default | +| --------------------------------- | ---------- | +| `captureStackTrace` | `false` | +| `connectionRetryLimit` | `3` | +| `connectionTimeout` | `5000 ms` | +| `idleInTransactionSessionTimeout` | `60000 ms` | +| `idleTimeout` | `5000 ms` | +| `maximumPoolSize` | `10` | +| `queryRetryLimit` | `5` | +| `statementTimeout` | `60000 ms` | +| `transactionRetryLimit` | `5` | + +### Feature 15 — Built-in interceptor chain + +Two interceptors are always active. You cannot disable them — if you need different behavior, do not use the main plugin and call `createDatabase` yourself. + +**fieldNameCaseConverter** converts every result row: + +``` +{ created_at: "2024-01-01", user_name: "alice" } +→ { createdAt: "2024-01-01", userName: "alice" } +``` + +**resultParser** validates rows when a query has a Zod schema: + +```typescript +import { sql } from "slonik"; +import { z } from "zod"; + +const userSchema = z.object({ id: z.number(), name: z.string() }); + +// Throws SchemaValidationError if the DB returns unexpected shape +const users = await conn.any(sql.type(userSchema)`SELECT id, name FROM users`); +``` + +### Feature 16 — Optional query-logging interceptor + +```typescript +await fastify.register(slonikPlugin, { + db: { ... }, + queryLogging: { enabled: true }, +}); +// Also requires ROARR_LOG=true in the environment +``` + +### Feature 17 — User interceptors merged + +```typescript +import type { Interceptor } from "slonik"; + +const myInterceptor: Interceptor = { + afterQueryExecution(context, query, result) { + metrics.recordQuery(query.sql, result.rowCount); + return result; + }, +}; + +await fastify.register(slonikPlugin, { + db: { ... }, + clientConfiguration: { + interceptors: [myInterceptor], // appended after built-ins + }, +}); +``` + +### Feature 18 — Extended type parsers + +`createBigintTypeParser` is always registered, converting PostgreSQL `bigint` / `int8` columns to JavaScript `number` instead of strings. + +### Feature 21 — `createBigintTypeParser` + +Exported for use outside the plugin (e.g., when calling `createDatabase` directly or building your own pool): + +```typescript +import { createBigintTypeParser } from "@prefabs.tech/fastify-slonik"; +import { createPool, createTypeParserPreset } from "slonik"; + +const pool = await createPool("postgres://...", { + typeParsers: [...createTypeParserPreset(), createBigintTypeParser()], +}); +``` + +### Feature 22 — `createDatabase` utility + +Creates a `Database` object from a connection string, without registering any Fastify decorators. Useful for scripts, tests, or service-layer code. + +```typescript +import { createDatabase } from "@prefabs.tech/fastify-slonik"; + +const db = await createDatabase("postgres://user:pw@localhost/mydb"); + +const rows = await db.connect((conn) => + conn.any(sql`SELECT id, name FROM users`), +); +``` + +### Features 23–30 — SQL fragment helpers + +These are the building blocks used by `DefaultSqlFactory`. You can use them directly when constructing custom queries. + +```typescript +import { + createFilterFragment, + createLimitFragment, + createSortFragment, + createTableFragment, + createTableIdentifier, + createWhereFragment, + createWhereIdFragment, + isValueExpression, +} from "@prefabs.tech/fastify-slonik"; +import { sql } from "slonik"; +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; + +const tableId = createTableIdentifier("users", "public"); +const tableRef = createTableFragment("users", "public"); + +const filters: FilterInput = { key: "status", operator: "eq", value: "active" }; +const sort: SortInput[] = [{ key: "createdAt", direction: "DESC" }]; + +const whereClause = createWhereFragment(tableId, filters, []); +const sortClause = createSortFragment(tableId, sort); +const limitClause = createLimitFragment(20, 40); // LIMIT 20 OFFSET 40 + +const query = sql.unsafe` + SELECT * FROM ${tableRef} + ${whereClause} + ${sortClause} + ${limitClause} +`; +``` + +`isValueExpression` is useful when building dynamic INSERT/UPDATE helpers: + +```typescript +const safe = isValueExpression(someValue); // false for plain objects / functions +``` + +### Features 31–38 — Filter system + +Build structured, composable WHERE clauses without writing raw SQL strings. + +```typescript +import type { FilterInput } from "@prefabs.tech/fastify-slonik"; + +// Simple equality +const f1: FilterInput = { key: "status", operator: "eq", value: "active" }; + +// Case-insensitive contains +const f2: FilterInput = { + key: "name", + operator: "ct", + value: "alice", + insensitive: true, +}; + +// Negated IN +const f3: FilterInput = { + key: "role", + operator: "in", + value: "admin,moderator", + not: true, +}; + +// BETWEEN +const f4: FilterInput = { + key: "createdAt", + operator: "bt", + value: "2024-01-01,2024-12-31", +}; + +// PostGIS proximity (lat, lng, radius in meters) +const f5: FilterInput = { + key: "location", + operator: "dwithin", + value: "51.5074,-0.1278,1000", +}; + +// NULL check +const f6: FilterInput = { key: "deletedAt", operator: "eq", value: "null" }; + +// Recursive AND / OR +const composed: FilterInput = { + AND: [f1, { OR: [f2, f3] }], +}; +``` + +Pass any `FilterInput` to `BaseService` methods or `DefaultSqlFactory.getFindSql`: + +```typescript +const users = await userService.find(composed, [ + { key: "name", direction: "ASC" }, +]); +``` + +### Features 39–55 — DefaultSqlFactory + +Extend `DefaultSqlFactory` to get type-safe, parameterized SQL for a table. + +```typescript +import { DefaultSqlFactory } from "@prefabs.tech/fastify-slonik"; +import { z } from "zod"; + +const userSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + deletedAt: z.string().nullable(), +}); + +class UserSqlFactory extends DefaultSqlFactory { + static override readonly TABLE = "users"; + + // Optional: use a Zod schema for automatic row validation + protected override _validationSchema = userSchema; + + // Optional: enable soft delete + protected override _softDeleteEnabled = true; +} +``` + +The factory generates all standard queries automatically: + +```typescript +const factory = new UserSqlFactory(fastify.config, fastify.slonik, "public"); + +// SELECT id, name FROM public.users ORDER BY id ASC +const allSql = factory.getAllSql(["id", "name"]); + +// SELECT COUNT(*) FROM public.users WHERE ... +const countSql = factory.getCountSql({ + key: "status", + operator: "eq", + value: "active", +}); + +// INSERT INTO public.users (name, email) VALUES ($1, $2) RETURNING * +const createSql = factory.getCreateSql({ + name: "Alice", + email: "alice@example.com", +}); + +// UPDATE public.users SET name = $1 WHERE id = $2 RETURNING * +const updateSql = factory.getUpdateSql(1, { name: "Alice B." }); + +// UPDATE public.users SET deleted_at = NOW() WHERE id = $1 RETURNING * +// (soft delete, because _softDeleteEnabled = true) +const deleteSql = factory.getDeleteSql(1); + +// DELETE FROM public.users WHERE id = $1 RETURNING * +// (force = true bypasses soft delete) +const hardDeleteSql = factory.getDeleteSql(1, true); +``` + +Override `getAdditionalFilterFragments` to inject permanent conditions into every read query: + +```typescript +import { sql } from "slonik"; + +class TenantSqlFactory extends DefaultSqlFactory { + static override readonly TABLE = "orders"; + + constructor( + config, + database, + schema, + private tenantId: number, + ) { + super(config, database, schema); + } + + protected override getAdditionalFilterFragments() { + return [sql.fragment`${this.tableIdentifier}.tenant_id = ${this.tenantId}`]; + } +} +``` + +### Features 56–67 — BaseService + +Extend `BaseService` to get a fully functional CRUD service backed by `DefaultSqlFactory`. + +```typescript +import { BaseService } from "@prefabs.tech/fastify-slonik"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { Database } from "@prefabs.tech/fastify-slonik"; + +type User = { id: number; name: string; email: string }; +type CreateUserDto = { name: string; email: string }; +type UpdateUserDto = Partial; + +class UserService extends BaseService { + // Override to use a custom factory + override get sqlFactoryClass() { + return UserSqlFactory; // UserSqlFactory from previous example + } +} + +const service = new UserService(fastify.config, fastify.slonik, "public"); + +// CRUD +const user = await service.create({ name: "Alice", email: "a@example.com" }); +const found = await service.findById(user!.id); +const updated = await service.update(user!.id, { name: "Alice B." }); +const deleted = await service.delete(user!.id); + +// Query +const activeUsers = await service.find({ + key: "status", + operator: "eq", + value: "active", +}); +const firstActive = await service.findOne({ + key: "status", + operator: "eq", + value: "active", +}); + +// Paginated list: { data, totalCount, filteredCount } +const page = await service.list(25, 0, { + key: "status", + operator: "eq", + value: "active", +}); + +// Count +const total = await service.count(); +``` + +### Feature 67 — Pre/post lifecycle hooks + +Override optional `pre` / `post` methods to transform data around DB calls. + +```typescript +class AuditedUserService extends BaseService< + User, + CreateUserDto, + UpdateUserDto +> { + override get sqlFactoryClass() { + return UserSqlFactory; + } + + // Called before create — return modified data or undefined to use original + protected override async preCreate( + data: CreateUserDto, + ): Promise { + return { ...data, name: data.name.trim() }; + } + + // Called after create — transform or enrich the result + protected override async postCreate(result: User): Promise { + await auditLog.record("user.created", result.id); + return result; + } + + // Called after list — example: mask sensitive fields + protected override async postList( + result: PaginatedList, + ): Promise> { + return { + ...result, + data: result.data.map((u) => ({ ...u, email: "***" })), + }; + } +} +``` + +### Feature 68 — `migrate` standalone utility + +Runs migrations outside of a Fastify application (e.g., in a CLI script): + +```typescript +import { migrate } from "@prefabs.tech/fastify-slonik"; // not directly exported from index; +// use migrationPlugin or call @prefabs.tech/postgres-migrations directly for CLI use. +``` + +The `migrate` function is used internally by `migrationPlugin`. For standalone CLI migration scripts, use `@prefabs.tech/postgres-migrations` directly or register `migrationPlugin` in a minimal Fastify app. + +### Feature 78 — `formatDate` + +Formats a `Date` to `"YYYY-MM-DD HH:mm:ss.SSS"` — the format PostgreSQL accepts for `timestamp without time zone` columns. + +```typescript +import { formatDate } from "@prefabs.tech/fastify-slonik"; + +const ts = formatDate(new Date()); // e.g. "2026-04-04 12:34:56.789" + +await conn.query(sql` + INSERT INTO events (name, occurred_at) + VALUES (${"user.login"}, ${ts}) +`); +``` + +--- + +## Use Cases + +### Use case 1 — Basic CRUD API route + +```typescript +import Fastify from "fastify"; +import slonikPlugin, { + BaseService, + DefaultSqlFactory, +} from "@prefabs.tech/fastify-slonik"; +import { z } from "zod"; + +const productSchema = z.object({ + id: z.number(), + name: z.string(), + price: z.number(), +}); +type Product = z.infer; + +class ProductFactory extends DefaultSqlFactory { + static override readonly TABLE = "products"; + protected override _validationSchema = productSchema; +} + +class ProductService extends BaseService< + Product, + Omit, + Partial> +> { + override get sqlFactoryClass() { + return ProductFactory; + } +} + +const fastify = Fastify(); +await fastify.register(slonikPlugin, { + db: { + host: "localhost", + databaseName: "shop", + username: "app", + password: "s3cr3t", + }, +}); + +fastify.get("/products", async (req) => { + const service = new ProductService(fastify.config, req.slonik); + const { data, totalCount } = await service.list(); + return { data, total: totalCount }; +}); + +fastify.post("/products", async (req, reply) => { + const service = new ProductService(fastify.config, req.slonik); + const product = await service.create(req.body as Omit); + return reply.status(201).send(product); +}); +``` + +### Use case 2 — Filtered and paginated list endpoint + +```typescript +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; + +fastify.get("/products/search", async (req) => { + const { + q, + minPrice, + maxPrice, + page = 0, + limit = 25, + } = req.query as Record; + + const filters: FilterInput = { + AND: [ + ...(q + ? [ + { + key: "name", + operator: "ct" as const, + value: q, + insensitive: true, + }, + ] + : []), + ...(minPrice && maxPrice + ? [ + { + key: "price", + operator: "bt" as const, + value: `${minPrice},${maxPrice}`, + }, + ] + : []), + ], + }; + + const sort: SortInput[] = [{ key: "name", direction: "ASC" }]; + + const service = new ProductService(fastify.config, req.slonik); + return service.list( + Number(limit), + Number(page) * Number(limit), + filters, + sort, + ); +}); +``` + +### Use case 3 — Multi-tenant schema routing + +```typescript +fastify.addHook("onRequest", async (req) => { + const tenantId = req.headers["x-tenant-id"] as string; + req.dbSchema = tenantId ? `tenant_${tenantId}` : "public"; +}); + +fastify.get("/orders", async (req) => { + const service = new OrderService(fastify.config, req.slonik, req.dbSchema); + return service.list(); +}); +``` + +### Use case 4 — Soft-delete with forced hard delete + +```typescript +const userSchema = z.object({ + id: z.number(), + name: z.string(), + deletedAt: z.string().nullable(), +}); +type User = z.infer; + +class SoftUserFactory extends DefaultSqlFactory { + static override readonly TABLE = "users"; + protected override _validationSchema = userSchema; + protected override _softDeleteEnabled = true; +} + +class SoftUserService extends BaseService< + User, + Omit, + { name?: string } +> { + override get sqlFactoryClass() { + return SoftUserFactory; + } +} + +fastify.delete("/users/:id", async (req, reply) => { + const { id } = req.params as { id: string }; + const { force } = req.query as { force?: string }; + const service = new SoftUserService(fastify.config, req.slonik); + + // Soft delete (sets deleted_at). Pass force=true for a hard DELETE. + const result = await service.delete(Number(id), force === "true"); + return result ?? reply.status(404).send({ message: "Not found" }); +}); +``` + +### Use case 5 — Custom SQL with fragment helpers + +When `DefaultSqlFactory` does not cover your query, compose it directly: + +```typescript +import { + createTableFragment, + createTableIdentifier, + createWhereFragment, + createSortFragment, +} from "@prefabs.tech/fastify-slonik"; +import { sql } from "slonik"; +import { z } from "zod"; + +fastify.get("/reports/top-buyers", async (req) => { + const tableId = createTableIdentifier("orders", "public"); + const tableRef = createTableFragment("orders", "public"); + + const where = createWhereFragment(tableId, undefined, [ + sql.fragment`${tableId}.status = 'completed'`, + ]); + + const sort = createSortFragment(tableId, [ + { key: "totalSpent", direction: "DESC" }, + ]); + + const reportSchema = z.object({ userId: z.number(), totalSpent: z.number() }); + + const rows = await req.slonik.connect((conn) => + conn.any(sql.type(reportSchema)` + SELECT user_id, SUM(amount) AS total_spent + FROM ${tableRef} + ${where} + GROUP BY user_id + ${sort} + LIMIT 10 + `), + ); + + return rows; +}); +``` + +### Use case 6 — Running database migrations on startup + +```typescript +import Fastify from "fastify"; +import { migrationPlugin } from "@prefabs.tech/fastify-slonik"; +import slonikPlugin from "@prefabs.tech/fastify-slonik"; + +const fastify = Fastify(); + +const dbConfig = { + db: { + host: "localhost", + port: 5432, + databaseName: "mydb", + username: "app", + password: "s3cr3t", + }, + migrations: { path: "db/migrations" }, +}; + +// Run migrations before the main plugin so the schema is up to date +await fastify.register(migrationPlugin, dbConfig); +await fastify.register(slonikPlugin, dbConfig); + +await fastify.listen({ port: 3000 }); +``` + +### Use case 7 — Using `createDatabase` in a service / script + +```typescript +import { createDatabase } from "@prefabs.tech/fastify-slonik"; +import { sql } from "slonik"; + +// Standalone script — no Fastify instance needed +const db = await createDatabase("postgres://app:s3cr3t@localhost/mydb"); + +const rows = await db.connect((conn) => + conn.any(sql`SELECT id, name FROM users WHERE active = true`), +); + +console.log(rows); +await db.pool.end(); +``` diff --git a/packages/slonik/README.md b/packages/slonik/README.md index 57fc1185c..6d16a18b5 100644 --- a/packages/slonik/README.md +++ b/packages/slonik/README.md @@ -1,15 +1,28 @@ # @prefabs.tech/fastify-slonik -A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of slonik in a fastify API. +A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of slonik in a fastify API. The plugin is a thin wrapper around the [`fastify-slonik`](https://github.com/spa5k/fastify-slonik) plugin. The plugin also includes logic to run migrations via [`@prefabs.tech/postgres-migrations`](https://github.com/prefabs-tech/postgres-migrations#readme) which is forked from [`postgres-migrations`](https://github.com/thomwright/postgres-migrations#readme). +## Why this plugin? + +Connecting an application to a PostgreSQL database isn't just about initiating a connection pool; it requires structurally sound ways to handle schema migrations, enforce strict type safety across SQL payloads, and quickly bootstrap generic data services. We created this plugin to: + +- **Unify Connections**: Bootstraps the PostgreSQL connection pool across Fastify, providing type-safe decorators (`fastify.slonik` and `fastify.sql`) to easily execute queries heavily verified at compile time. +- **Automate Migrations**: Safely executes pending database migrations directly at application boot-up (via `@prefabs.tech/postgres-migrations`), avoiding complex external CLI requirements in automated deployments. +- **Provide Data-Layer Scaffolding**: It isn't just a basic database driver wrapper; it ships with standard `BaseService` and `DefaultSqlFactory` abstract classes that natively handle boilerplate CRUD tasks, geo-filtering (`dwithin`), and API sorting conventions. + +### Design Decisions: Why not Prisma or TypeORM? Why Slonik? + +1. **Performance and Predictability**: Traditional heavyweight ORMs like Prisma or TypeORM often generate unpredictable, wildly inefficient SQL queries at massive scale. Slonik forces you to write explicit, hyper-optimized raw SQL while flawlessly protecting you from SQL injection vulnerabilities through tagged template literals. +2. **First-Class TypeScript Types**: By using Slonik, we retain total architectural control over strict database interactions and execution planning while enjoying near-perfect TypeScript synchronization—without suffering the penalty of learning a restrictive proprietary query dialect. + ## Requirements -* [@prefabs.tech/fastify-config](../config/) -* [slonik](https://github.com/gajus/slonik) +- [@prefabs.tech/fastify-config](../config/) +- [slonik](https://github.com/gajus/slonik) ## Installation @@ -81,16 +94,16 @@ const start = async () => { const fastify = Fastify({ logger: config.logger, }); - + // Register fastify-config plugin await fastify.register(configPlugin, { config }); - + // Register fastify-slonik plugin await fastify.register(slonikPlugin, config.slonik); - + // Run database migrations await fastify.register(migrationPlugin, config.slonik); - + await fastify.listen({ port: config.port, host: "0.0.0.0", @@ -99,14 +112,17 @@ const start = async () => { start(); ``` + **Note: `migrationPlugin` should be registered after all the plugins.** ### Support for geo-filtering using `dwithin` + This package supports the filter for fetching the data from specific geographic area. This can return the data within specific area from the given co-ordinate point. Prerequisite: Ensure that PostGIS extension is enabled before using this filter. Reference: [Setting up PostGIS](https://postgis.net/documentation/getting_started/install_windows/enabling_postgis/) Example: + ``` { "key": "", "operator": "dwithin", "value": ",," } ``` @@ -115,13 +131,12 @@ Example: ### `db` - -| Attribute | Type | Description | -|------------|------|-------------| -| `database` | `string` | The name of the database to connect to. | -| `host` | `string` | The database's host. | +| Attribute | Type | Description | +| ---------- | -------- | -------------------------------------------- | +| `database` | `string` | The name of the database to connect to. | +| `host` | `string` | The database's host. | | `password` | `string` | The password for connecting to the database. | -| `port` | `number` | The database's port. | +| `port` | `number` | The database's port. | | `username` | `string` | The username for connecting to the database. | ### `migrations` @@ -131,6 +146,7 @@ Paths to the migrations files. You can specify 1 path per environment. Currently The path must be relative to node.js `process.cwd()`. ### Enabling query logging + To enable query logging, set `queryLogging.enabled` to `true` in the slonik config and set `ROARR_LOG=true` environment variable to ensure logs are printed to the console. ```typescript diff --git a/packages/slonik/feature.md b/packages/slonik/feature.md new file mode 100644 index 000000000..455f32a63 --- /dev/null +++ b/packages/slonik/feature.md @@ -0,0 +1,2146 @@ +# @prefabs.tech/fastify-slonik Complete Features Reference + +A comprehensive Fastify plugin providing PostgreSQL database integration via Slonik with automatic migrations, type safety, and CRUD scaffolding. This document details every feature, capability, configuration option, and fix added by this plugin. + +**Version**: 0.93.5 +**License**: MIT + +--- + +## Table of Contents + +1. [Connection & Initialization](#1-connection--initialization) +2. [Core CRUD Operations](#2-core-crud-operations) +3. [Soft Delete Support](#3-soft-delete-support) +4. [Filtering System](#4-filtering-system) +5. [Sorting & Pagination](#5-sorting--pagination) +6. [Service Hooks](#6-service-hooks-pre--post-operations) +7. [Field Name Conversion](#7-field-name-conversion-camelcase--snake_case) +8. [Database Migrations](#8-database-migrations) +9. [SQL Factory & Generation](#9-sql-factory--generation) +10. [Data Transformation & Type Safety](#10-data-transformation--type-safety) +11. [Utilities & Helpers](#11-utilities--helpers) +12. [Special Behaviors & Optimizations](#12-special-behaviors--optimizations) +13. [Feature Matrix](#feature-matrix) +14. [Developer Workflow Example](#developer-workflow-example) +15. [Configuration Reference](#configuration-reference) +16. [Feature Checklist](#feature-checklist-for-developers) +17. [Performance Considerations](#performance-considerations) +18. [Automatic vs Manual](#whats-automatically-handled) + +--- + +## 1. CONNECTION & INITIALIZATION + +### 1.1 PostgreSQL Connection Pool Management + +**What it does**: Automatically establishes and maintains a connection pool to PostgreSQL with automatic retry logic. + +**Features**: + +- Connection retry logic: 3 automatic retries on connection failures +- Configurable pool size: Default max 10 connections +- Connection timeout: 5 seconds +- Query retry limit: 5 attempts +- Transaction retry limit: 5 attempts +- Statement timeout: 60 seconds +- Idle connection management: 5 second idle timeout, 60 second idle-in-transaction timeout + +**How developers use it**: + +```typescript +import slonikPlugin from "@prefabs.tech/fastify-slonik"; +import Fastify from "fastify"; + +const fastify = Fastify(); +await fastify.register(slonikPlugin, { + db: { + host: "localhost", + port: 5432, + username: "postgres", + password: "password", + databaseName: "myapp", + }, +}); + +// Connection pool automatically created with retry logic +``` + +**Internal config defaults**: + +- `captureStackTrace: false` +- `connectionRetryLimit: 3` +- `connectionTimeout: 5000` ms +- `idleInTransactionSessionTimeout: 60000` ms +- `idleTimeout: 5000` ms +- `maximumPoolSize: 10` +- `queryRetryLimit: 5` +- `statementTimeout: 60000` ms +- `transactionRetryLimit: 5` + +--- + +### 1.2 Fastify Decorators (Instance-Level) + +**What it does**: Provides database access through Fastify instance decorators available globally. + +**Decorators added to `fastify`**: + +- `fastify.slonik` - Database object with pool and query methods +- `fastify.sql` - Slonik SQL template tag for safe query building +- `fastify.config` - ApiConfig containing slonik configuration + +**Usage**: + +```typescript +fastify.get("/users", async (req, reply) => { + // Access via fastify instance + const users = await fastify.slonik.pool.any(fastify.sql`SELECT * FROM users`); + return users; +}); +``` + +--- + +### 1.3 Request-Level Database Access (Request Decorators) + +**What it does**: Automatically injects database access into every request, avoiding need to pass context. + +**Decorators added to `req`**: + +- `req.slonik` - Same database object as fastify.slonik +- `req.sql` - Slonik SQL template tag +- `req.dbSchema` - Schema name (customizable per request, defaults to empty string for public schema) + +**Implementation**: Registered via `onRequest` hook that fires on every request + +**Usage**: + +```typescript +fastify.post("/articles", async (req, reply) => { + // req.slonik and req.sql available automatically + const article = await req.slonik.pool.one( + req.sql` + INSERT INTO articles (title, content) + VALUES (${req.body.title}, ${req.body.content}) + RETURNING * + `, + ); + return article; +}); +``` + +--- + +### 1.4 Connection Testing on Startup + +**What it does**: Verifies database connectivity when plugin initializes. + +**How it works**: + +- Executes a test connection: `db.pool.connect(async () => {})` +- Throws error if connection fails, preventing app from starting with bad config +- Ensures database is reachable before accepting requests + +--- + +## 2. CORE CRUD OPERATIONS + +### 2.1 BaseService Foundation Class + +**What it does**: Abstract base class providing ready-made CRUD operations to avoid repetitive boilerplate. + +**How to use**: + +```typescript +import BaseService from "@prefabs.tech/fastify-slonik"; +import type { Database } from "@prefabs.tech/fastify-slonik"; + +class UserService extends BaseService { + static readonly TABLE = "users"; + + constructor(config: ApiConfig, database: Database) { + super(config, database); + } +} + +// In route handler +const userService = new UserService(config, fastify.slonik); +``` + +**Generic Parameters**: + +- `T` - Entity type (what's returned from database) +- `C` - Creation input type (what's passed to create) +- `U` - Update input type (what's passed to update) + +**Required static property**: + +- `TABLE: string` - Name of the database table + +**Optional static properties for defaults**: + +- `LIMIT_DEFAULT: number` - Default page size (default: 20) +- `LIMIT_MAX: number` - Maximum allowable page size (default: 50) +- `SORT_DIRECTION: "ASC" | "DESC"` - Default sort direction (default: "ASC") +- `SORT_KEY: string` - Default sort column (default: "id") + +--- + +### 2.2 Read Operations + +#### create(data: C): Promise + +Creates a new record and returns it. + +```typescript +const newUser = await userService.create({ + name: "John Doe", + email: "john@example.com", + age: 30, +}); +``` + +**Features**: + +- Automatically includes `preCreate` hook +- Automatically includes `postCreate` hook +- Returns undefined if creation fails +- Only includes fields with valid ValueExpression types (null, string, number, boolean, Date, Buffer, arrays) + +--- + +#### read(id: string | number): Promise + +Retrieves a single record by ID. + +```typescript +const user = await userService.findById(1); +// Returns user object or null if not found +``` + +**Aliases**: `findById()` is the actual method name + +--- + +#### find(filters?: FilterInput, sort?: SortInput[]): Promise + +Retrieves all records matching optional filters with optional sorting. + +```typescript +// Get all users +const allUsers = await userService.find(); + +// Get with filters +const admins = await userService.find({ + key: "role", + operator: "eq", + value: "admin", +}); + +// Get with sorting +const sorted = await userService.find(undefined, [ + { key: "createdAt", direction: "DESC" }, + { key: "name", direction: "ASC" }, +]); +``` + +**Features**: + +- No pagination applied (returns all matching records) +- Supports multiple filters combined with AND/OR +- Supports multi-column sorting +- Default sort: ORDER BY id ASC +- Excludes soft-deleted records if soft delete enabled + +--- + +#### findOne(filters?: FilterInput, sort?: SortInput[]): Promise + +Retrieves first matching record or null. + +```typescript +const admin = await userService.findOne({ + key: "role", + operator: "eq", + value: "admin", +}); +``` + +**Features**: + +- Uses LIMIT 1 for efficiency +- Returns null if no match found +- Respects sorting order + +--- + +#### all(fields: string[], sort?: SortInput[]): Promise> + +Retrieves all records with only specified fields (useful for dropdowns/selects). + +```typescript +// Get just id and name for a country selector +const countries = await userService.all(["id", "name"]); +// Returns: [{ id: 1, name: "USA" }, { id: 2, name: "Canada" }] +``` + +**Features**: + +- No pagination +- Field projection for smaller payloads +- Reduces data transfer + +--- + +#### count(filters?: FilterInput): Promise + +Returns total count of records matching optional filters. + +```typescript +const totalUsers = await userService.count(); +const activeUsers = await userService.count({ + key: "status", + operator: "eq", + value: "active", +}); +``` + +--- + +#### list(limit?: number, offset?: number, filters?: FilterInput, sort?: SortInput[]): Promise> + +Retrieves paginated results with count information. + +```typescript +const result = await userService.list( + 20, // limit (capped at maxLimit) + 0, // offset + { key: "status", operator: "eq", value: "active" }, // filters + [{ key: "createdAt", direction: "DESC" }], // sort +); + +// Returns: +// { +// totalCount: 1500, // Total records in table +// filteredCount: 234, // Records matching filter +// data: [...] // Paginated result set +// } +``` + +**Features**: + +- Enforces limit constraints (capped at maxLimit from config) +- Returns totalCount (for total record count) +- Returns filteredCount (for filtered subset count) +- Returns data array +- Respects filter and sort parameters + +--- + +### 2.3 Write Operations + +#### update(id: string | number, data: U): Promise + +Updates record by ID and returns updated record. + +```typescript +const updated = await userService.update(1, { + name: "Jane Doe", + status: "active", +}); +``` + +**Features**: + +- Triggers `preUpdate` hook for data transformation +- Triggers `postUpdate` hook for output transformation +- Returns updated record from database +- Only includes fields with valid ValueExpression types + +--- + +#### delete(id: string | number, force?: boolean): Promise + +Deletes record by ID (soft or hard delete depending on configuration). + +```typescript +// Soft delete (if enabled) +await userService.delete(1); + +// Force hard delete even if soft delete enabled +await userService.delete(1, true); +``` + +**Features**: + +- Respects soft delete configuration +- If soft delete enabled: sets deleted_at timestamp, keeps record in database +- If soft delete disabled or force=true: permanently removes record +- Triggers `preDelete` hook for validation +- Triggers `postDelete` hook for cleanup +- Returns deleted record or null + +--- + +## 3. SOFT DELETE SUPPORT + +### 3.1 Enabling Soft Deletes + +**What it does**: Marks records as deleted with timestamp instead of permanently removing them. + +**How to enable**: + +```typescript +class UserService extends BaseService { + static readonly TABLE = "users"; + + constructor(config: ApiConfig, database: Database) { + super(config, database); + this._softDeleteEnabled = true; // Enable soft deletes + } +} +``` + +**Prerequisite**: Database table must have `deleted_at` column (timestamp nullable). + +--- + +### 3.2 Automatic Soft Delete Filtering + +**What it does**: When soft delete is enabled, all queries automatically exclude deleted records. + +**Affected methods**: + +- `findById()` - Excludes soft-deleted records +- `find()` - Excludes soft-deleted records +- `findOne()` - Excludes soft-deleted records +- `all()` - Excludes soft-deleted records +- `count()` - Excludes soft-deleted records +- `list()` - Excludes soft-deleted records + +**Generated SQL**: + +- Adds `WHERE deleted_at IS NULL` filter automatically +- Developer doesn't need to remember to exclude deleted records +- All queries work as if deleted records don't exist + +--- + +### 3.3 Forcing Hard Delete + +**What it does**: Permanently removes record even if soft delete enabled. + +```typescript +// Soft delete (sets deleted_at) +await userService.delete(userId); + +// Force hard delete (permanent removal) +await userService.delete(userId, true); +``` + +--- + +## 4. FILTERING SYSTEM + +### 4.1 Overview + +**What it does**: Provides 11+ filter operators for building flexible WHERE clauses with AND/OR logic, all parameterized to prevent SQL injection. + +**Basic Filter Structure**: + +```typescript +type BaseFilterInput = { + key: string; // Field name to filter on + operator: operator; // One of 11 operators (see below) + value: string; // Filter value as string + not?: boolean; // Optional: negate the operator + insensitive?: boolean; // Optional: case-insensitive matching +}; + +type FilterInput = + | BaseFilterInput + | { AND: FilterInput[] } // Combine multiple filters with AND + | { OR: FilterInput[] }; // Combine multiple filters with OR +``` + +--- + +### 4.2 Equality Operator (eq) + +**Operator**: `"eq"` +**SQL Generated**: `=` or `IS NULL` +**Use**: Match exact values + +```typescript +// Simple equality +const admins = await userService.find({ + key: "role", + operator: "eq", + value: "admin", +}); + +// NULL check (special handling) +const deleted = await userService.find({ + key: "deletedAt", + operator: "eq", + value: "null", // Check IS NULL +}); + +// Negate (not equal) +const nonAdmins = await userService.find({ + key: "role", + operator: "eq", + value: "admin", + not: true, // Inverts to != +}); +``` + +--- + +### 4.3 Case-Insensitive Equality + +**Operator**: `"eq"` with `insensitive: true` +**SQL Generated**: `LOWER(unaccent(column)) = LOWER(unaccent(value))` +**Use**: Match values ignoring case and accents + +```typescript +const user = await userService.findOne({ + key: "email", + operator: "eq", + value: "JOHN@EXAMPLE.COM", + insensitive: true, // Matches "john@example.com" +}); + +// Works with accents too +const person = await userService.find({ + key: "name", + operator: "eq", + value: "jose", + insensitive: true, // Matches "José" (requires unaccent extension) +}); +``` + +**Prerequisite**: `insensitive` feature requires PostgreSQL `unaccent` extension (automatically created by plugin). + +--- + +### 4.4 String Pattern Operators + +#### Contains (ct) + +**Operator**: `"ct"` +**SQL Generated**: `ILIKE '%value%'` +**Use**: Match substring anywhere in field + +```typescript +const results = await userService.find({ + key: "name", + operator: "ct", + value: "john", +}); +// Matches: "John", "johnny", "John Doe", "benjamin" +``` + +--- + +#### Starts With (sw) + +**Operator**: `"sw"` +**SQL Generated**: `ILIKE 'value%'` +**Use**: Match string prefix + +```typescript +const results = await userService.find({ + key: "email", + operator: "sw", + value: "admin@", +}); +// Matches: "admin@example.com", "admin@company.org" +``` + +--- + +#### Ends With (ew) + +**Operator**: `"ew"` +**SQL Generated**: `ILIKE '%value'` +**Use**: Match string suffix + +```typescript +const results = await userService.find({ + key: "email", + operator: "ew", + value: "@example.com", +}); +// Matches: "john@example.com", "jane@example.com" +``` + +--- + +### 4.5 Comparison Operators + +#### Greater Than (gt) + +**Operator**: `"gt"` +**SQL Generated**: `> value` +**Use**: Numeric and date comparisons + +```typescript +const recentArticles = await userService.find({ + key: "createdAt", + operator: "gt", + value: "2024-01-01", +}); +``` + +--- + +#### Greater Than or Equal (gte) + +**Operator**: `"gte"` +**SQL Generated**: `>= value` + +```typescript +const adults = await userService.find({ + key: "age", + operator: "gte", + value: "18", +}); +``` + +--- + +#### Less Than (lt) + +**Operator**: `"lt"` +**SQL Generated**: `< value` + +```typescript +const affordable = await userService.find({ + key: "price", + operator: "lt", + value: "100", +}); +``` + +--- + +#### Less Than or Equal (lte) + +**Operator**: `"lte"` +**SQL Generated**: `<= value` + +```typescript +const limitedStock = await userService.find({ + key: "stock", + operator: "lte", + value: "10", +}); +``` + +--- + +### 4.6 IN Operator + +**Operator**: `"in"` +**SQL Generated**: `IN (value1, value2, ...)` +**Use**: Match any value from a list + +```typescript +// Find users with specific roles (comma-separated values) +const results = await userService.find({ + key: "role", + operator: "in", + value: "admin,moderator,editor", +}); + +// Negate to get NOT IN +const nonStaff = await userService.find({ + key: "status", + operator: "in", + value: "inactive,banned", + not: true, // Converts to NOT IN +}); +``` + +--- + +### 4.7 BETWEEN Operator + +**Operator**: `"bt"` +**SQL Generated**: `BETWEEN start AND end` +**Use**: Range filtering for numbers and dates + +```typescript +// Date range +const articles = await userService.find({ + key: "publishedAt", + operator: "bt", + value: "2024-01-01,2024-12-31", // comma-separated start,end +}); + +// Price range +const products = await userService.find({ + key: "price", + operator: "bt", + value: "10,100", +}); +``` + +--- + +### 4.8 Geographic Distance Operator (PostGIS) + +**Operator**: `"dwithin"` +**SQL Generated**: `ST_DWithin(geometry_column, ST_Point(long, lat), radius)` +**Use**: Find records within geographic radius + +```typescript +const nearbyStores = await storeService.find({ + key: "location", // Geographic point column + operator: "dwithin", + value: "40.7128,-74.0060,5000", // latitude,longitude,radius_in_meters +}); +// Returns stores within 5km of NYC coordinates +``` + +**Prerequisite**: Requires PostgreSQL PostGIS extension (can be enabled in config). + +**Format**: `"latitude,longitude,radius_in_meters"` + +--- + +### 4.9 Negation + +**Feature**: All operators can be inverted with `not` flag + +```typescript +// NOT equal +const admins = await userService.find({ + key: "role", + operator: "eq", + value: "admin", + not: true, // NOT role = 'admin' +}); + +// NOT contains +const excluded = await userService.find({ + key: "name", + operator: "ct", + value: "spam", + not: true, // Names NOT containing 'spam' +}); + +// NOT in +const active = await userService.find({ + key: "status", + operator: "in", + value: "inactive,banned", + not: true, // NOT IN clause +}); +``` + +--- + +### 4.10 Combining Filters with AND/OR + +#### AND Logic + +```typescript +// Multiple conditions all must be true +const activeAdmins = await userService.find({ + AND: [ + { key: "role", operator: "eq", value: "admin" }, + { key: "status", operator: "eq", value: "active" }, + ], +}); +// WHERE role = 'admin' AND status = 'active' +``` + +#### OR Logic + +```typescript +// Any condition can be true +const important = await userService.find({ + OR: [ + { key: "priority", operator: "eq", value: "high" }, + { key: "priority", operator: "eq", value: "urgent" }, + ], +}); +// WHERE priority = 'high' OR priority = 'urgent' +``` + +#### Complex Nested Filters + +```typescript +// (role = admin AND status = active) OR (role = moderator AND status = active) +const active = await userService.find({ + OR: [ + { + AND: [ + { key: "role", operator: "eq", value: "admin" }, + { key: "status", operator: "eq", value: "active" }, + ], + }, + { + AND: [ + { key: "role", operator: "eq", value: "moderator" }, + { key: "status", operator: "eq", value: "active" }, + ], + }, + ], +}); +``` + +--- + +### 4.11 Accent-Insensitive Filtering + +**Feature**: Remove accents from characters during comparison + +```typescript +// Finds "José", "jose", "Jóse", "JOSÉ" all the same +const user = await userService.find({ + key: "name", + operator: "ct", + value: "jose", + insensitive: true, // Enables unaccent +}); +``` + +**How it works**: + +- Wraps filter value in PostgreSQL `unaccent()` function +- Wraps database column in `unaccent()` function +- Case-insensitive comparison via `LOWER()` +- Requires PostgreSQL `unaccent` extension + +**Prerequisite**: Plugin automatically creates `unaccent` extension on startup. + +--- + +### 4.12 Filtering Auto-Conversion (camelCase to snake_case) + +**What it does**: Automatically converts JavaScript field names to database column names. + +```typescript +// API input uses camelCase +const users = await userService.find({ + key: "createdAt", // camelCase in API + operator: "gt", + value: "2024-01-01", +}); + +// Plugin converts to created_at automatically for SQL +// SELECT * FROM users WHERE created_at > '2024-01-01' +``` + +--- + +## 5. SORTING & PAGINATION + +### 5.1 Multi-Column Sorting + +**What it does**: Sort results by one or more columns in ascending or descending order. + +```typescript +// Sort by createdAt DESC, then name ASC +const users = await userService.find( + undefined, // filters + [ + { key: "createdAt", direction: "DESC" }, + { key: "name", direction: "ASC" }, + ], +); +// Generated SQL: ORDER BY created_at DESC, name ASC +``` + +**SortInput Type**: + +```typescript +type SortInput = { + key: string; // Column name + direction: "ASC" | "DESC"; // Sort direction + insensitive?: boolean; // Case-insensitive sorting +}; +``` + +--- + +### 5.2 Default Sorting + +**What it does**: If no sort specified, automatically sorts by ID ascending. + +```typescript +const users = await userService.find(); // Defaults to ORDER BY id ASC +``` + +**Customizable defaults**: + +```typescript +class CustomService extends DefaultSqlFactory { + static readonly SORT_KEY = "createdAt"; + static readonly SORT_DIRECTION = "DESC"; + // Now defaults to ORDER BY created_at DESC +} +``` + +--- + +### 5.3 Case-Insensitive Sorting + +**What it does**: Sort strings ignoring case differences. + +```typescript +const users = await userService.find(undefined, [ + { key: "name", direction: "ASC", insensitive: true }, +]); +// Uses: LOWER(name) ASC +``` + +--- + +### 5.4 Offset-Based Pagination + +**What it does**: Paginate through results using limit and offset. + +```typescript +// Page 1: limit=20, offset=0 +const page1 = await userService.list(20, 0); + +// Page 2: limit=20, offset=20 +const page2 = await userService.list(20, 20); + +// Page 3: limit=20, offset=40 +const page3 = await userService.list(20, 40); +``` + +**Generated SQL**: `LIMIT 20 OFFSET 0` + +--- + +### 5.5 Pagination Limit Enforcement + +**What it does**: Enforce maximum record limits to prevent resource exhaustion. + +```typescript +// Request 100 records, but only get max 50 (default maxLimit) +const result = await userService.list(100, 0); +// Returns only 50 records, not 100 +``` + +**Configuration**: + +```typescript +const config = { + slonik: { + pagination: { + defaultLimit: 20, // If limit not specified + maxLimit: 50, // Hard cap on limit + }, + }, +}; +``` + +**Behavior**: + +- If limit not provided: uses defaultLimit (20) +- If limit exceeds maxLimit: capped at maxLimit +- Prevents developers from requesting massive datasets accidentally + +--- + +### 5.6 Paginated List with Counts + +**What it does**: Get paginated results with comprehensive count information. + +```typescript +const result = await userService.list( + 10, // limit + 0, // offset + { key: "status", operator: "eq", value: "active" }, + [{ key: "createdAt", direction: "DESC" }], +); + +// Returns object with: +console.log(result.totalCount); // Total records in table +console.log(result.filteredCount); // Records matching filter +console.log(result.data); // Paginated result set (10 records) +``` + +**Use Cases**: + +- UI pagination: Show "Showing 1-10 of 234 active users" +- Understand dataset size: Know if need to show next page button +- Analytics: Count filtered vs total records + +--- + +## 6. SERVICE HOOKS (PRE & POST OPERATIONS) + +### 6.1 Pre-Operation Hooks + +**What it does**: Transform or validate input before database operations execute. + +**Available Pre-Hooks**: + +#### preCreate(data: C): Promise + +Called before INSERT operation. + +```typescript +class UserService extends BaseService { + static readonly TABLE = "users"; + + protected async preCreate(data: CreateUserInput) { + // Hash password before creating + return { + ...data, + password: await bcrypt.hash(data.password, 10), + email: data.email.toLowerCase(), + }; + } +} +``` + +**Use Cases**: + +- Password hashing +- Email normalization +- Default value assignment +- Validation (throw error to reject) +- Data enrichment + +--- + +#### preUpdate(data: U): Promise + +Called before UPDATE operation. + +```typescript +protected async preUpdate(data: UpdateUserInput) { + return { + ...data, + email: data.email?.toLowerCase(), + updatedAt: new Date(), + }; +} +``` + +--- + +#### preDelete(id: string | number): Promise + +Called before DELETE operation (no return value). + +```typescript +protected async preDelete(id: number) { + const user = await this.findById(id); + + // Validation: prevent deleting last admin + if (user?.role === "admin" && await this.isLastAdmin()) { + throw new Error("Cannot delete the last admin user"); + } +} +``` + +**Use Cases**: + +- Validation before destructive operations +- Preventing deletion of critical records +- Checking permissions +- Cleanup of related data + +--- + +#### preFind(), preFindById(id), preFindOne(): Promise + +Called before SELECT operations (no parameters or return value). + +```typescript +protected async preFindById(id: number) { + // Log access for audit trail + await auditLog.record("user_accessed", { userId: id }); +} +``` + +--- + +#### preAll(), preCount(), preList(): Promise + +Additional pre-hooks for other operations. + +--- + +### 6.2 Post-Operation Hooks + +**What it does**: Transform or enrich output after database operations complete. + +**Available Post-Hooks**: + +#### postFindById(result: T): Promise + +Called after SELECT by ID, can modify returned record. + +```typescript +protected async postFindById(user: User) { + // Strip sensitive fields from returned user + const { password, twoFactorSecret, ...safe } = user; + return safe; +} +``` + +**Use Cases**: + +- Remove sensitive data (passwords, secrets) +- Enrich with computed properties +- Format dates or other data types +- Add flags based on current user + +--- + +#### postFind(result: readonly T[]): Promise + +Called after SELECT multiple records. + +```typescript +protected async postFind(users: readonly User[]) { + // Add computed isAdmin flag + return users.map(user => ({ + ...user, + isAdmin: user.role === "admin", + displayName: `${user.firstName} ${user.lastName}`, + })); +} +``` + +--- + +#### postList(result: PaginatedList): Promise> + +Called after paginated SELECT, can modify data array, counts, or entire result. + +```typescript +protected async postList(result: PaginatedList) { + return { + ...result, + data: result.data.map(user => ({ + ...user, + hasActiveSessions: await this.checkActiveSessions(user.id), + })), + }; +} +``` + +--- + +#### postCreate(result: T): Promise + +Called after INSERT, can modify created record before returning to caller. + +```typescript +protected async postCreate(user: User) { + // Send welcome email + await emailService.sendWelcomeEmail(user.email); + + // Log creation + await auditLog.record("user_created", { userId: user.id }); + + return user; +} +``` + +--- + +#### postUpdate(result: T): Promise + +Called after UPDATE. + +```typescript +protected async postUpdate(user: User) { + // Invalidate cache + await cache.invalidateUser(user.id); + + return user; +} +``` + +--- + +#### postDelete(result: T): Promise + +Called after DELETE (soft or hard). + +```typescript +protected async postDelete(user: User) { + // Clean up related resources + await sessionService.invalidateUserSessions(user.id); + await fileService.deleteUserFiles(user.id); + + return user; +} +``` + +--- + +#### postAll(result: Partial): Promise> + +Called after SELECT with field projection. + +--- + +#### postCount(result: number): Promise + +Called after COUNT operation. + +--- + +### 6.3 Hook Execution Order + +**Flow for find(filters) with hooks**: + +1. `preFind()` executes +2. SQL query built with filters +3. SQL query executed against database +4. Field names converted (snake_case → camelCase) +5. Results validated against schema if provided +6. `postFind(results)` executes on transformed results +7. Final results returned to caller + +**Hook Error Handling**: + +- If pre-hook throws: operation aborted, error propagated +- If post-hook throws: database operation already committed, error propagated to caller + +--- + +## 7. FIELD NAME CONVERSION (camelCase ↔ snake_case) + +### 7.1 Automatic Output Conversion + +**What it does**: Database columns (snake_case) automatically converted to JavaScript properties (camelCase). + +**Implementation**: Applied by `fieldNameCaseConverter` interceptor on every query result. + +```typescript +// Database table columns: user_id, first_name, created_at, updated_at +const user = await fastify.slonik.pool.one( + fastify.sql`SELECT user_id, first_name, created_at FROM users WHERE id = ${1}`, +); + +// Result automatically has camelCase keys: +console.log(user.userId); // ✓ Works (from user_id) +console.log(user.firstName); // ✓ Works (from first_name) +console.log(user.createdAt); // ✓ Works (from created_at) +console.log(user.user_id); // ✗ Undefined +``` + +**How it works**: Uses `humps.camelizeKeys()` on every row returned from database. + +--- + +### 7.2 Automatic Input Conversion + +**What it does**: JavaScript objects automatically converted to snake_case for database operations. + +```typescript +// create() with camelCase input +const newUser = await userService.create({ + firstName: "John", + lastName: "Doe", + emailAddress: "john@example.com", +}); + +// Automatically converted for INSERT: +// INSERT INTO users (first_name, last_name, email_address) +// VALUES ('John', 'Doe', 'john@example.com') +``` + +**How it works**: Uses `humps.decamelize()` when building INSERT/UPDATE SQL. + +--- + +### 7.3 Filter Field Conversion + +**What it does**: Filter keys automatically converted from camelCase to snake_case. + +```typescript +const users = await userService.find({ + key: "createdAt", // camelCase in API + operator: "gt", + value: "2024-01-01", +}); + +// Automatically becomes: WHERE created_at > '2024-01-01' +``` + +--- + +### 7.4 Sort Field Conversion + +**What it does**: Sort column names automatically converted from camelCase to snake_case. + +```typescript +const users = await userService.find(undefined, [ + { key: "createdAt", direction: "DESC" }, // camelCase + { key: "firstName", direction: "ASC" }, // camelCase +]); + +// Automatically becomes: ORDER BY created_at DESC, first_name ASC +``` + +--- + +## 8. DATABASE MIGRATIONS + +### 8.1 Auto-Running Migrations on Startup + +**What it does**: Automatically executes pending migrations when application boots. + +**Setup**: + +```typescript +import slonikPlugin, { migrationPlugin } from "@prefabs.tech/fastify-slonik"; + +const fastify = Fastify(); +await fastify.register(configPlugin, { config }); +await fastify.register(slonikPlugin, config.slonik); +await fastify.register(migrationPlugin, config.slonik); // Runs migrations + +// Note: migrationPlugin must be registered AFTER slonikPlugin +``` + +**How it works**: + +1. Reads migration directory specified in config +2. Checks which migrations have been run (tracks in \_migrations table) +3. Executes any pending migrations in order +4. Updates \_migrations table on successful execution +5. Throws error if migration fails, preventing app from starting + +**Benefits**: + +- No external CLI tools needed +- Database always in correct state when app starts +- Automated deployments don't need separate migration step + +--- + +### 8.2 Environment-Specific Migration Paths + +**What it does**: Use different migration directories for development and production. + +```typescript +const config = { + slonik: { + db: { + /* ... */ + }, + migrations: { + development: "migrations", // Dev migrations directory + production: "build/migrations", // Compiled migrations for prod + }, + }, +}; +``` + +**Use Cases**: + +- Development: Reference source migrations directly +- Production: Use compiled/built migrations +- Different sets of seed data per environment + +--- + +### 8.3 PostgreSQL Extensions Setup + +**What it does**: Automatically creates required PostgreSQL extensions on startup. + +**Default extensions** created: + +- `citext` - Case-insensitive text type +- `unaccent` - Text search support for accent-insensitive matching + +**Configuration**: + +```typescript +const config = { + slonik: { + db: { + /* ... */ + }, + extensions: ["citext", "unaccent", "postgis", "uuid-ossp"], + }, +}; +``` + +**Available Extensions**: + +- `citext` - For case-insensitive text fields +- `unaccent` - For accent-insensitive searching +- `postgis` - For geographic data (latitude/longitude) +- `uuid-ossp` - For UUID generation +- Custom extensions as needed + +**How it works**: + +1. Extensions specified in config are merged with defaults +2. Duplicates removed +3. Each extension created with `CREATE EXTENSION IF NOT EXISTS` +4. Runs on every startup (idempotent, safe to run multiple times) + +**Benefits**: + +- No manual setup needed +- Extensions available for migrations to use +- Enables use of special data types (PostGIS, citext, etc.) + +--- + +## 9. SQL FACTORY & GENERATION + +### 9.1 DefaultSqlFactory Overview + +**What it does**: Abstract factory class that generates parameterized SQL queries. + +**Used by**: BaseService internally (developers don't typically use directly). + +**How BaseService uses it**: + +```typescript +class UserService extends BaseService { + static readonly TABLE = "users"; + static readonly LIMIT_DEFAULT = 30; // Override default + static readonly LIMIT_MAX = 100; // Override max + + get sqlFactoryClass() { + return DefaultSqlFactory; // Can override for custom factory + } +} +``` + +--- + +### 9.2 SQL Generation Methods + +#### SQL for Fetching Records + +- `getListSql(limit, offset, filters, sort)` - LIMIT/OFFSET pagination +- `getFindSql(filters, sort)` - No pagination +- `getFindOneSql(filters, sort)` - LIMIT 1 +- `getFindByIdSql(id)` - Simple SELECT by primary key +- `getAllSql(fields, sort)` - Fetch specific fields only +- `getCountSql(filters)` - COUNT(\*) + +--- + +#### SQL for Modifying Records + +- `getCreateSql(data)` - INSERT with RETURNING \* +- `getUpdateSql(id, data)` - UPDATE with RETURNING \* +- `getDeleteSql(id, force)` - DELETE or UPDATE (soft delete) + +--- + +### 9.3 Static Configuration Properties + +These can be overridden in subclass: + +```typescript +class ProductSqlFactory extends DefaultSqlFactory { + static readonly TABLE = "products"; // Required + static readonly LIMIT_DEFAULT = 50; // Default page size + static readonly LIMIT_MAX = 200; // Max page size + static readonly SORT_DIRECTION = "DESC"; // ASC or DESC + static readonly SORT_KEY = "createdAt"; // Default sort column +} +``` + +--- + +### 9.4 Schema Support + +**What it does**: Query different database schemas (not public schema). + +```typescript +// Query from analytics schema instead of public +class ReportService extends BaseService< + Report, + CreateReportInput, + UpdateReportInput +> { + static readonly TABLE = "reports"; + + constructor(config: ApiConfig, database: Database) { + super(config, database, "analytics"); // Specify schema as 3rd param + } +} + +// Generates: SELECT * FROM analytics.reports +``` + +--- + +## 10. DATA TRANSFORMATION & TYPE SAFETY + +### 10.1 Type-Safe Query Execution with Zod + +**What it does**: Validates query results against Zod schemas at runtime. + +```typescript +import { z } from "zod"; + +const userSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + age: z.number().optional(), +}); + +// Results validated and typed as User +const user = await fastify.slonik.pool.one( + fastify.sql.type(userSchema)`SELECT * FROM users WHERE id = ${1}`, +); + +// TypeScript knows user.id, user.name, user.email, user.age +// Runtime validation ensures data matches schema +``` + +**How it works**: + +1. `sql.type(schema)` sets schema on queryContext +2. Query executes +3. `resultParser` interceptor validates each row +4. Throws `SchemaValidationError` if row doesn't match schema +5. Returns typed result + +**Benefits**: + +- Type safety at compile time (TypeScript) +- Data validation at runtime (Zod) +- Catches corrupted data before reaching application +- Self-documenting result types + +--- + +### 10.2 Date Formatting + +**What it does**: Convert JavaScript dates to PostgreSQL-compatible format. + +```typescript +import { formatDate } from "@prefabs.tech/fastify-slonik"; + +const now = new Date(); +const formatted = formatDate(now); +// Returns: "2024-03-15 14:30:45.123" + +const user = await userService.create({ + name: "John", + createdAt: formatted, +}); +``` + +**Format**: `YYYY-MM-DD HH:mm:ss.SSS` (PostgreSQL timestamp format without timezone) + +--- + +### 10.3 BigInt Type Parsing + +**What it does**: Handle PostgreSQL `int8` (64-bit integer) values in JavaScript. + +```typescript +// Configured automatically in plugin +const stats = await fastify.slonik.pool.one( + fastify.sql.type(z.object({ views: z.number() }))` + SELECT views FROM analytics WHERE id = ${1} + `, +); + +console.log(typeof stats.views); // "number" +console.log(stats.views); // Parsed as regular JavaScript number +``` + +**How it works**: + +- Creates custom Slonik type parser for `int8` +- Converts PostgreSQL int64 values to JavaScript Number +- Parser: `Number.parseInt(value, 10)` + +**Note**: `@todo` comment in code suggests future support for native BigInt when value > Number.MAX_SAFE_INTEGER. + +--- + +## 11. UTILITIES & HELPERS + +### 11.1 Direct Database Connection + +**What it does**: Manually create database connection pool (useful for CLI scripts, tests). + +```typescript +import { createDatabase } from "@prefabs.tech/fastify-slonik"; + +const db = await createDatabase("postgresql://user:pass@localhost/myapp", { + connectionRetryLimit: 5, + maximumPoolSize: 20, +}); + +const users = await db.pool.any(db.sql`SELECT * FROM users`); +``` + +--- + +### 11.2 Direct SQL Execution + +**What it does**: Execute raw parameterized queries outside of BaseService. + +**Via request** (inside route handler): + +```typescript +fastify.get("/users/:id", async (req, reply) => { + const user = await req.slonik.pool.one( + req.sql`SELECT * FROM users WHERE id = ${req.params.id}`, + ); + return user; +}); +``` + +**Via fastify instance**: + +```typescript +const result = await fastify.slonik.query( + fastify.sql`UPDATE users SET status = ${"active"} WHERE id = ${1}`, +); +``` + +**Via database object**: + +```typescript +const users = await db.pool.any(db.sql`SELECT * FROM users`); +``` + +--- + +### 11.3 Connection Routine Execution + +**What it does**: Execute code within a single database connection context. + +```typescript +const user = await db.connect(async (connection) => { + const created = await connection.query( + sql`INSERT INTO users (name) VALUES ${"John"} RETURNING *`, + ); + + const preferences = await connection.query( + sql`INSERT INTO preferences (user_id) VALUES ${created.id} RETURNING *`, + ); + + return { created, preferences }; +}); +``` + +--- + +### 11.4 Exported Type Definitions + +Available for TypeScript: + +```typescript +// Core types +export type PaginatedList = { + totalCount: number; + filteredCount: number; + data: readonly T[]; +}; + +export interface Service { + create(data: C): Promise; + find(filters?: FilterInput, sort?: SortInput[]): Promise; + findById(id: string | number): Promise; + findOne(filters?: FilterInput, sort?: SortInput[]): Promise; + list( + limit?: number, + offset?: number, + filters?: FilterInput, + sort?: SortInput[], + ): Promise>; + update(id: string | number, data: U): Promise; + delete(id: string | number, force?: boolean): Promise; + all(fields: string[], sort?: SortInput[]): Promise>; + count(filters?: FilterInput): Promise; +} + +export type FilterInput = + | BaseFilterInput + | { AND: FilterInput[] } + | { OR: FilterInput[] }; +export type SortInput = { + key: string; + direction: "ASC" | "DESC"; + insensitive?: boolean; +}; +export type Database = { + pool: DatabasePool; + query: QueryFunction; + connect: ConnectionRoutine; +}; +``` + +--- + +## 12. SPECIAL BEHAVIORS & OPTIMIZATIONS + +### 12.1 SQL Injection Prevention + +**What it does**: Prevents SQL injection through parameterized queries. + +**Mechanism**: + +- All queries use Slonik's tagged template literals +- Values automatically parameterized and bound +- Column/table identifiers safely escaped via `sql.identifier()` +- Even filter values safely bound + +```typescript +// Safe: value parameterized +await userService.find({ + key: "email", + operator: "eq", + value: "admin'; DROP TABLE users; --", // Safely escaped, not executable +}); + +// Safe: column name identifier +const column = "name"; // Even if from user input +const sql = `SELECT * FROM users WHERE ${sql.identifier([column])} = $1`; +``` + +--- + +### 12.2 Connection Pool & Retry Logic + +**What it does**: Automatic retries and connection management for resilience. + +**Retry Configuration** (all automatic, no code needed): + +- Connection retry limit: 3 attempts +- Query retry limit: 5 attempts +- Transaction retry limit: 5 attempts + +**Timeouts**: + +- Connection timeout: 5 seconds +- Idle timeout: 5 seconds +- Idle-in-transaction timeout: 60 seconds +- Statement timeout: 60 seconds + +**Benefits**: + +- Transient failures don't crash app +- Long-running queries don't hang indefinitely +- Idle connections cleaned up automatically + +--- + +### 12.3 Value Type Validation + +**What it does**: Ensures only safe types included in SQL queries. + +```typescript +const isValueExpression = (value) => { + // Accepts: null, string, number, boolean, Date, Buffer, arrays of ValueExpressions + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value instanceof Date || + value instanceof Buffer || + Array.isArray(value) + ); +}; + +// In create/update: Only includes fields with valid ValueExpression values +// Invalid types (functions, objects, undefined) automatically filtered out +``` + +--- + +### 12.4 Query Logging + +**What it does**: Optional SQL query logging for debugging and monitoring. + +**Enable**: + +```typescript +const config = { + slonik: { + db: { + /* ... */ + }, + queryLogging: { + enabled: true, + }, + }, +}; + +// Start app with: ROARR_LOG=true npm run dev +``` + +**Output**: + +``` +SQL queries logged directly to console via Roarr logger +All parameterized values included +Execution time shown +``` + +**Limitation**: Roarr logger is independent from Fastify Pino logger; logs to console only (doesn't support file output natively). + +--- + +### 12.5 Result Validation & Schema Enforcement + +**What it does**: Optional runtime validation of query results against Zod schemas. + +```typescript +const schema = z.object({ + id: z.number(), + email: z.string().email(), +}); + +const user = await fastify.slonik.pool.one( + fastify.sql.type(schema)`SELECT * FROM users WHERE id = ${1}`, +); + +// If row doesn't match schema: +// - Throws SchemaValidationError +// - Includes field-by-field validation errors +// - Prevents corrupt/unexpected data from propagating +``` + +--- + +### 12.6 Configuration Validation + +**What it does**: Validates configuration on plugin registration. + +```typescript +// If required fields missing, plugin throws before app starts +await fastify.register(slonikPlugin, { + db: { + host: process.env.DB_HOST, // Must be provided + port: 5432, + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + databaseName: process.env.DB_NAME, + }, +}); +``` + +--- + +### 12.7 Lazy SqlFactory Initialization + +**What it does**: SqlFactory instances created only when needed. + +**Benefit**: Reduces memory overhead if service methods not called, improves startup time. + +--- + +## FEATURE MATRIX + +| Feature | Category | Direct Use | Indirect Use | Configurable | +| -------------------------- | ----------- | ------------------------ | ----------------------- | --------------------------- | +| PostgreSQL Connection Pool | Core | Via req.slonik | Automatic | Yes | +| Connection Retries (3x) | Core | N/A | Automatic | Yes | +| Fastify Decorators | Core | Via req.slonik, req.sql | Every request | N/A | +| CRUD via BaseService | Core | Method calls | Service base | Via static properties | +| Soft Delete Support | Data | delete(id, force) | Automatic filtering | Via \_softDeleteEnabled | +| 11+ Filter Operators | Query | find(filters) | All find methods | N/A (built-in) | +| AND/OR Composition | Query | find({ AND: [...] }) | All find methods | N/A | +| Case-Insensitive Search | Query | insensitive: true | Filter processing | Requires unaccent extension | +| Geographic Filtering | Query | operator: "dwithin" | find() with geo data | Requires PostGIS | +| Multi-Column Sorting | Query | sort parameter | find(), list() | Via SortInput | +| Offset Pagination | Pagination | list(limit, offset) | Built-in | Via config | +| Limit Enforcement | Pagination | N/A | Automatic capping | Via config.pagination | +| Paginated Counts | Pagination | list() return value | Pagination feature | N/A | +| Pre-Operation Hooks | Extension | protected preCreate() | Service methods | Override method | +| Post-Operation Hooks | Extension | protected postFindById() | Service methods | Override method | +| camelCase ↔ snake_case | Convenience | Automatic | All queries/results | Via interceptor | +| Auto Migrations | Startup | migrationPlugin | App boot | Configurable path | +| Extension Management | Startup | N/A | Auto-created | Via config.extensions | +| Type-Safe Queries (Zod) | Type Safety | sql.type(schema) | Query validation | Via schema parameter | +| Date Formatting | Conversion | formatDate(date) | Manual use | Via utility | +| BigInt Parsing | Type Safety | N/A | Automatic | Via type parser | +| Query Logging | Debugging | N/A | Via interceptor | Via queryLogging.enabled | +| SQL Injection Prevention | Security | N/A | All queries (automatic) | N/A | +| Value Type Validation | Security | N/A | create/update filter | N/A | + +--- + +## DEVELOPER WORKFLOW EXAMPLE + +Here's how these features work together in a typical API endpoint: + +```typescript +// 1. Define service with hooks +class ProductService extends BaseService< + Product, + CreateProductInput, + UpdateProductInput +> { + static readonly TABLE = "products"; + static readonly LIMIT_DEFAULT = 20; + static readonly LIMIT_MAX = 100; + + protected async preCreate(data: CreateProductInput) { + // Normalize data + return { + ...data, + name: data.name.trim(), + slug: data.name.toLowerCase().replace(/\s+/g, "-"), + }; + } + + protected async postList(result) { + // Enrich results + return { + ...result, + data: result.data.map((p) => ({ + ...p, + inStock: p.quantity > 0, + })), + }; + } +} + +// 2. Use in route handler (camelCase input converted to snake_case automatically) +fastify.get("/products", async (req, reply) => { + const result = await new ProductService(config, req.slonik).list( + 20, // limit + 0, // offset + { + AND: [ + // insensitive: true enables unaccent + lowercase + { key: "name", operator: "ct", value: "laptop", insensitive: true }, + // Comparison operators work too + { key: "price", operator: "bt", value: "100,2000" }, + // NOT support + { key: "status", operator: "eq", value: "discontinued", not: true }, + ], + }, + [ + { key: "createdAt", direction: "DESC" }, // Converted to created_at + { key: "name", direction: "ASC" }, + ], + ); + + // Returns: + // { + // totalCount: 5000, + // filteredCount: 237, + // data: [ + // { id: 1, name: "Laptop Pro", inStock: true, ... }, + // ... + // ] + // } + + return result; +}); + +// 3. Features involved: +// - Filter parsing with 5 operators (ct, bt, eq, not) +// - Case-insensitive search (insensitive: true) +// - AND logic combining filters +// - Multi-column sorting with auto field conversion +// - Soft delete auto-filtering (if enabled) +// - Automatic camelCase → snake_case for all fields +// - postList hook enriching results +// - Limit enforcement (max 100) +// - Count information for pagination UI +``` + +--- + +## CONFIGURATION REFERENCE + +Complete configuration object structure: + +```typescript +interface SlonikOptions { + // PostgreSQL Connection Details (required) + db: { + host: string; // Database host + port: number; // Database port (default: 5432) + username: string; // Database user + password: string; // Database password + databaseName: string; // Database name + }; + + // Slonik Client Configuration (optional) + clientConfiguration?: { + captureStackTrace?: boolean; // default: false + connectionRetryLimit?: number; // default: 3 + connectionTimeout?: number; // default: 5000 ms + idleInTransactionSessionTimeout?: number; // default: 60000 ms + idleTimeout?: number; // default: 5000 ms + maximumPoolSize?: number; // default: 10 + queryRetryLimit?: number; // default: 5 + statementTimeout?: number; // default: 60000 ms + transactionRetryLimit?: number; // default: 5 + ssl?: boolean | object; // TLS/SSL config + }; + + // Pagination Settings (optional) + pagination?: { + defaultLimit?: number; // default: 20 if not specified + maxLimit?: number; // default: 50 (hard cap) + }; + + // Migration Paths (optional) + migrations?: { + development?: string; // default: "migrations" + production?: string; // default: "build/migrations" + }; + + // PostgreSQL Extensions to create (optional) + extensions?: string[]; // default: ["citext", "unaccent"] + // can add: "postgis", "uuid-ossp", etc + + // Query Logging (optional) + queryLogging?: { + enabled?: boolean; // default: false + // Requires ROARR_LOG=true env var to see output + }; +} +``` + +--- + +## FEATURE CHECKLIST FOR DEVELOPERS + +When building API endpoints with this plugin, you have these tools at your disposal: + +### Query Operations + +- [x] SELECT single by ID (`findById()`) +- [x] SELECT single by filters (`findOne()`) +- [x] SELECT multiple (`find()`) +- [x] SELECT with pagination (`list()`) +- [x] SELECT specific fields only (`all()`) +- [x] COUNT records (`count()`) + +### Filtering + +- [x] Exact match (`eq`) +- [x] Case-insensitive exact (`insensitive: true`) +- [x] String contains (`ct`) +- [x] String starts with (`sw`) +- [x] String ends with (`ew`) +- [x] Numeric range (`bt`) +- [x] Comparison operators (`gt`, `gte`, `lt`, `lte`) +- [x] IN list (`in`) +- [x] Geographic distance (`dwithin`) +- [x] NOT/negation (`not: true`) +- [x] AND/OR composition (AND/OR arrays) +- [x] Nested complex filters (arbitrary depth) + +### Sorting + +- [x] Single column sort +- [x] Multi-column sort +- [x] Ascending/descending +- [x] Case-insensitive sort (`insensitive: true`) +- [x] Default sort behavior +- [x] Automatic camelCase conversion + +### Data Operations + +- [x] Create records (`create()`) +- [x] Update records (`update()`) +- [x] Soft delete (`delete()` with soft delete enabled) +- [x] Hard delete (`delete(id, true)`) + +### Type Safety + +- [x] Zod schema validation on queries +- [x] TypeScript generics on BaseService +- [x] Type inference on results +- [x] Runtime validation + +### Auto Features + +- [x] camelCase ↔ snake_case conversion +- [x] SQL injection prevention +- [x] Connection pooling & retries +- [x] Migration execution +- [x] Extension creation (citext, unaccent, postgis, etc) +- [x] Soft delete filtering (if enabled) + +### Extensibility + +- [x] Pre-hooks (preCreate, preUpdate, preDelete, etc) +- [x] Post-hooks (postCreate, postUpdate, postDelete, etc) +- [x] Custom SQL factories +- [x] Schema-specific queries +- [x] Custom result enrichment + +--- + +## PERFORMANCE CONSIDERATIONS + +This plugin is designed for performance: + +1. **Query Efficiency**: Raw SQL via Slonik (not ORM overhead) +2. **Connection Pooling**: Max 10 connections by default, configurable +3. **Retry Logic**: Automatic retries for transient failures +4. **Lazy Initialization**: SqlFactory created only when used +5. **Limit Enforcement**: Prevents accidental massive queries +6. **Caching**: Relies on PostgreSQL query cache, not app-level caching +7. **No N+1 Problems**: Encourages explicit queries vs automatic relations + +**Best Practices**: + +- Use `find()` for specific queries, not `list()` when pagination not needed +- Use `all(fields)` to project specific columns, reducing data transfer +- Use filters with indexes to avoid full table scans +- Enable query logging during development to review generated SQL +- Use schema-specific queries if splitting by tenant + +--- + +## WHAT'S AUTOMATICALLY HANDLED + +The plugin handles these concerns transparently: + +- ✓ Establishing database connection +- ✓ Retrying failed connections +- ✓ Managing connection pool lifecycle +- ✓ Converting camelCase field names to/from snake_case +- ✓ Executing migrations at startup +- ✓ Creating PostgreSQL extensions +- ✓ Injecting database access into all requests +- ✓ Validating query results against schemas +- ✓ Preventing SQL injection +- ✓ Filtering soft-deleted records +- ✓ Type-checking query results +- ✓ Parsing PostgreSQL int8 to JavaScript numbers + +--- + +## WHAT DEVELOPERS CONTROL + +These aspects are customizable: + +- Connection pool size and timeouts +- Default and maximum pagination limits +- Migration paths per environment +- PostgreSQL extensions to create +- Field filtering and sorting logic +- Pre/post operation hook logic +- Result transformation and enrichment +- Custom SQL generation (via factory override) +- Database schema used (via 3rd param to BaseService) +- Query logging enable/disable + +--- diff --git a/packages/slonik/package-lock.json b/packages/slonik/package-lock.json deleted file mode 100644 index 73f8ff937..000000000 --- a/packages/slonik/package-lock.json +++ /dev/null @@ -1,5626 +0,0 @@ -{ - "name": "@prefabs.tech/fastify-slonik", - "version": "0.93.4", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@prefabs.tech/fastify-slonik", - "version": "0.93.4", - "license": "MIT", - "dependencies": { - "@prefabs.tech/postgres-migrations": "5.4.3", - "humps": "2.0.1", - "pg": "8.16.3", - "slonik-interceptor-query-logging": "46.8.0" - }, - "devDependencies": { - "@prefabs.tech/eslint-config": "0.4.0", - "@prefabs.tech/fastify-config": "0.93.4", - "@prefabs.tech/tsconfig": "0.2.0", - "@slonik/driver": "46.8.0", - "@types/humps": "2.0.6", - "@types/node": "24.10.0", - "@types/pg": "8.15.5", - "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.6.1", - "fastify-plugin": "5.1.0", - "pg-mem": "3.0.5", - "prettier": "3.6.2", - "slonik": "46.8.0", - "typescript": "5.9.3", - "vite": "6.4.1", - "vitest": "3.2.4", - "zod": "3.25.76" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.4", - "fastify": ">=5.2.1", - "fastify-plugin": ">=5.0.1", - "pg-mem": ">=3.0.2", - "slonik": ">=46.1.0", - "zod": ">=3.23.8" - }, - "peerDependenciesMeta": { - "pg-mem": { - "optional": true - } - } - }, - "../../../tools/packages/eslint-config": { - "name": "@prefabs.tech/eslint-config", - "version": "0.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint/js": "9.39.2", - "eslint-config-prettier": "10.1.8", - "eslint-import-resolver-alias": "1.1.2", - "eslint-import-resolver-typescript": "4.4.4", - "eslint-plugin-import": "2.32.0", - "eslint-plugin-n": "17.20.0", - "eslint-plugin-prettier": "5.5.5", - "eslint-plugin-promise": "7.2.1", - "eslint-plugin-unicorn": "62.0.0", - "eslint-plugin-vue": "10.7.0", - "globals": "17.3.0", - "typescript-eslint": "8.54.0", - "vue-eslint-parser": "10.2.0" - }, - "devDependencies": { - "eslint": "9.39.2", - "prettier": "3.8.1", - "typescript": "5.9.3" - }, - "peerDependencies": { - "eslint": ">=8.57.1 <=9.39.2", - "prettier": ">=3.3.3", - "typescript": ">=4.9.5" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@fastify/ajv-compiler": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", - "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@fastify/error": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", - "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", - "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "fast-json-stringify": "^6.0.0" - } - }, - "node_modules/@fastify/forwarded": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", - "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@fastify/proxy-addr": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", - "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/forwarded": "^3.0.0", - "ipaddr.js": "^2.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@prefabs.tech/eslint-config": { - "resolved": "../../../tools/packages/eslint-config", - "link": true - }, - "node_modules/@prefabs.tech/fastify-config": { - "version": "0.93.4", - "resolved": "https://registry.npmjs.org/@prefabs.tech/fastify-config/-/fastify-config-0.93.4.tgz", - "integrity": "sha512-Oj892sMSEstK9GL07asciN9NTI7byt9EK5LNXOTudlGJzbuQKKYwQigdcegR17EFvqI2gXIG62ro4VK+/FXavA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "fastify": ">=5.2.1", - "fastify-plugin": ">=5.0.1" - } - }, - "node_modules/@prefabs.tech/postgres-migrations": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@prefabs.tech/postgres-migrations/-/postgres-migrations-5.4.3.tgz", - "integrity": "sha512-9cpcwgI0nZaUWmmybYuqhvroCEZHXy9IQmjdM83UF/yUXNu25ASZ09xfw96Q5Ixq0nYwtqkivorvpayBm+/XXg==", - "license": "MIT", - "dependencies": { - "pg": "^8.6.0", - "sql-template-strings": "^2.2.2" - }, - "bin": { - "pg-validate-migrations": "dist/bin/validate.js" - }, - "engines": { - "node": ">10.17.0" - } - }, - "node_modules/@prefabs.tech/tsconfig": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@prefabs.tech/tsconfig/-/tsconfig-0.2.0.tgz", - "integrity": "sha512-AUsEmP7j7NnfEYa18u4bkXJlijk2duaKaEhhhfeTCOj01nNRMhaqGWYkYaphdiUR4mSU3e1+wkVvJBbX8Ri3fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/tsconfig": "0.1.3" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@slonik/driver": { - "version": "46.8.0", - "resolved": "https://registry.npmjs.org/@slonik/driver/-/driver-46.8.0.tgz", - "integrity": "sha512-cA19zOfUHZenDhUt4xfLHbLRyd3V4mxc4ojkhERCn3hJpYwe7RpIV/85NvevbGjiiVIvXoVwwOOvz8XlCElJBg==", - "license": "BSD-3-Clause", - "dependencies": { - "@slonik/types": "^46.8.0", - "@slonik/utilities": "^46.8.0", - "roarr": "^7.21.1", - "serialize-error": "^8.0.0", - "strict-event-emitter-types": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3" - } - }, - "node_modules/@slonik/errors": { - "version": "46.8.0", - "resolved": "https://registry.npmjs.org/@slonik/errors/-/errors-46.8.0.tgz", - "integrity": "sha512-tgAGNKNeG8696c7Vab6wvHkiTq2mz4dl+TgNJbucVr2Q4lvoO2dAT07+YplO4yCTLwH2thoy9gObA4fz+/Wjgw==", - "license": "BSD-3-Clause", - "dependencies": { - "@slonik/types": "^46.8.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3" - } - }, - "node_modules/@slonik/pg-driver": { - "version": "46.8.0", - "resolved": "https://registry.npmjs.org/@slonik/pg-driver/-/pg-driver-46.8.0.tgz", - "integrity": "sha512-ene0QxhG/xpaWULuBhWGYND14NZolDCEQNnBvz31KtegWvAo9piZsK/DhVpHP/qww7JnVMrvXORTToXNUDiiNQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@slonik/driver": "^46.8.0", - "@slonik/errors": "^46.8.0", - "@slonik/sql-tag": "^46.8.0", - "@slonik/types": "^46.8.0", - "@slonik/utilities": "^46.8.0", - "pg": "^8.13.1", - "pg-query-stream": "^4.9.6", - "pg-types": "^4.0.2", - "postgres-array": "^3.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3" - } - }, - "node_modules/@slonik/pg-driver/node_modules/pg-types": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz", - "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@slonik/pg-driver/node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@slonik/pg-driver/node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "license": "MIT", - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@slonik/pg-driver/node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@slonik/pg-driver/node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@slonik/sql-tag": { - "version": "46.8.0", - "resolved": "https://registry.npmjs.org/@slonik/sql-tag/-/sql-tag-46.8.0.tgz", - "integrity": "sha512-jdUnlA7n/z16/3oFYZ9FAaf4VBkEUBx+oXjIdUQXMmyYiNwKqqjXiEteyHFMaEIY9710crrLQGWGXIKhipZ8Sw==", - "license": "BSD-3-Clause", - "dependencies": { - "@slonik/errors": "^46.8.0", - "@slonik/types": "^46.8.0", - "roarr": "^7.21.1", - "safe-stable-stringify": "^2.5.0", - "serialize-error": "^8.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3" - } - }, - "node_modules/@slonik/types": { - "version": "46.8.0", - "resolved": "https://registry.npmjs.org/@slonik/types/-/types-46.8.0.tgz", - "integrity": "sha512-Fl9aPDVQj7xd8Ny2+uEH8PQGwks3tiKn5Ttt86NMHcjuZaWyxlXUJejTZ6PquPwBS3jhsc95/c8awONXOgnORg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3" - } - }, - "node_modules/@slonik/utilities": { - "version": "46.8.0", - "resolved": "https://registry.npmjs.org/@slonik/utilities/-/utilities-46.8.0.tgz", - "integrity": "sha512-1y6RM5JvAPEwQs5ei8+0CbTvmHAcKVkWm7112AJLiE/5+qV2OqrQkgDEB/Rug9aNAEGZv9/it0BdW5wNBEozAg==", - "license": "BSD-3-Clause", - "dependencies": { - "@slonik/errors": "^46.8.0", - "@slonik/types": "^46.8.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/humps": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/humps/-/humps-2.0.6.tgz", - "integrity": "sha512-Fagm1/a/1J9gDKzGdtlPmmTN5eSw/aaTzHtj740oSfo+MODsSY2WglxMmhTdOglC8nxqUhGGQ+5HfVtBvxo3Kg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", - "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/pg": { - "version": "8.15.5", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", - "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@vitest/coverage-istanbul": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-3.2.4.tgz", - "integrity": "sha512-IDlpuFJiWU9rhcKLkpzj8mFu/lpe64gVgnV15ZOrYx1iFzxxrxCzbExiUEKtwwXRvEiEMUS6iZeYgnMxgbqbxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@istanbuljs/schema": "^0.1.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-instrument": "^6.0.3", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magicast": "^0.3.5", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "3.2.4" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vue/tsconfig": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.1.3.tgz", - "integrity": "sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/abstract-logging": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", - "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/avvio": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", - "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@fastify/error": "^4.0.0", - "fastq": "^1.17.1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001768", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", - "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/crack-json": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/crack-json/-/crack-json-1.3.0.tgz", - "integrity": "sha512-JfZ9NPLsU9ejTYgZ7fM+5TIMfTwROTxpi2Twh597GxmiVDwIGZSjaor+zsQBKZ0mmCKOFb9EZZLVeKNf/5UaGg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=8.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stringify": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.2.0.tgz", - "integrity": "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/merge-json-schemas": "^0.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0", - "json-schema-ref-resolver": "^3.0.0", - "rfdc": "^1.2.0" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-printf": { - "version": "1.6.10", - "resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz", - "integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=10.0" - } - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastify": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.6.1.tgz", - "integrity": "sha512-WjjlOciBF0K8pDUPZoGPhqhKrQJ02I8DKaDIfO51EL0kbSMwQFl85cRwhOvmSDWoukNOdTo27gLN549pLCcH7Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@fastify/ajv-compiler": "^4.0.0", - "@fastify/error": "^4.0.0", - "@fastify/fast-json-stringify-compiler": "^5.0.0", - "@fastify/proxy-addr": "^5.0.0", - "abstract-logging": "^2.0.1", - "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", - "light-my-request": "^6.0.0", - "pino": "^9.0.0", - "process-warning": "^5.0.0", - "rfdc": "^1.3.1", - "secure-json-parse": "^4.0.0", - "semver": "^7.6.0", - "toad-cache": "^3.7.0" - } - }, - "node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-my-way": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", - "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stack-trace": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/get-stack-trace/-/get-stack-trace-3.1.1.tgz", - "integrity": "sha512-E1rM+umbm9MlMp6zNSap+UI8VVWWmAoUxiAHp1Ron1FV2dM99mgMAHS1tGAGO/ceBjgOXz24GC47aLeNN1llrA==", - "dependencies": { - "stacktrace-parser": "^0.1.10" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/humps": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", - "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==", - "license": "MIT" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/iso8601-duration": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/iso8601-duration/-/iso8601-duration-1.3.0.tgz", - "integrity": "sha512-K4CiUBzo3YeWk76FuET/dQPH03WE04R94feo5TSKQCXpoXQt9E4yx2CnY737QZnSAI3PI4WlKo/zfqizGx52QQ==", - "license": "MIT" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", - "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "isarray": "^2.0.5", - "jsonify": "^0.0.1", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", - "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", - "dev": true, - "license": "Public Domain", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/light-my-request": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", - "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "cookie": "^1.0.1", - "process-warning": "^4.0.0", - "set-cookie-parser": "^2.6.0" - } - }, - "node_modules/light-my-request/node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/moo": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", - "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nearley": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", - "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/pg": { - "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.9.1", - "pg-pool": "^3.10.1", - "pg-protocol": "^1.10.3", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.2.7" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", - "license": "MIT" - }, - "node_modules/pg-cursor": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.17.0.tgz", - "integrity": "sha512-2Uio3Xfl5ldwJfls+RgGL+YbPcKQncWACWjYQFqlamvHZ4HJFjZhhZBbqd7jQ2LIkZYSvU90bm2dNW0rno+QFQ==", - "license": "MIT", - "peerDependencies": { - "pg": "^8" - } - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-mem": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/pg-mem/-/pg-mem-3.0.5.tgz", - "integrity": "sha512-Bh8xHD6u/wUXCoyFE2vyRs5pgaKbqjWFQowKDlbKWCiF0vOlo2A0PZdiUxmf2PKgb6Vb6C7gwAlA7jKvsfDHZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "functional-red-black-tree": "^1.0.1", - "immutable": "^4.3.4", - "json-stable-stringify": "^1.0.1", - "lru-cache": "^6.0.0", - "moment": "^2.27.0", - "object-hash": "^2.0.3", - "pgsql-ast-parser": "^12.0.1" - }, - "peerDependencies": { - "@mikro-orm/core": ">=4.5.3", - "@mikro-orm/postgresql": ">=4.5.3", - "knex": ">=0.20", - "kysely": ">=0.26", - "pg-promise": ">=10.8.7", - "pg-server": "^0.1.5", - "postgres": "^3.4.4", - "slonik": ">=23.0.1", - "typeorm": ">=0.2.29" - }, - "peerDependenciesMeta": { - "@mikro-orm/core": { - "optional": true - }, - "@mikro-orm/postgresql": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mikro-orm": { - "optional": true - }, - "pg-promise": { - "optional": true - }, - "pg-server": { - "optional": true - }, - "postgres": { - "optional": true - }, - "slonik": { - "optional": true - }, - "typeorm": { - "optional": true - } - } - }, - "node_modules/pg-mem/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pg-mem/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/pg-numeric": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", - "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", - "license": "ISC", - "engines": { - "node": ">=4" - } - }, - "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", - "license": "MIT" - }, - "node_modules/pg-query-stream": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.12.0.tgz", - "integrity": "sha512-H97oiVPQ0+eRqIFOeYMUnjDcv9od7vHHMjiVDAhg2SEzAUr3M/dT83UEV1B+fm+tcVnymI8j2LSp57/+yjF6Fg==", - "license": "MIT", - "dependencies": { - "pg-cursor": "^2.17.0" - }, - "peerDependencies": { - "pg": "^8" - } - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/pgsql-ast-parser": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz", - "integrity": "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "moo": "^0.5.1", - "nearley": "^2.19.5" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-range": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", - "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pretty-ms": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", - "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", - "license": "MIT", - "dependencies": { - "parse-ms": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "dev": true, - "license": "MIT" - }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/roarr": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-7.21.4.tgz", - "integrity": "sha512-qvfUKCrpPzhWmQ4NxRYnuwhkI5lwmObhBU06BCK/lpj6PID9nL4Hk6XDwek2foKI+TMaV+Yw//XZshGF2Lox/Q==", - "license": "BSD-3-Clause", - "dependencies": { - "fast-printf": "^1.6.9", - "safe-stable-stringify": "^2.4.3", - "semver-compare": "^1.0.0" - }, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-regex2": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", - "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - } - }, - "node_modules/safe-regex2/node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT" - }, - "node_modules/serialize-error": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", - "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "dev": true, - "license": "MIT" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slonik": { - "version": "46.8.0", - "resolved": "https://registry.npmjs.org/slonik/-/slonik-46.8.0.tgz", - "integrity": "sha512-1sBzz4k5eowrzaGvP0gdZ41p62K1FxC7tDpA/IWvyGpf7A51eGNoAVfrg/mL9+OifWnKuru9opHPrdE15UHpfg==", - "license": "BSD-3-Clause", - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@slonik/driver": "^46.8.0", - "@slonik/errors": "^46.8.0", - "@slonik/pg-driver": "^46.8.0", - "@slonik/sql-tag": "^46.8.0", - "@slonik/utilities": "^46.8.0", - "get-stack-trace": "^3.1.1", - "iso8601-duration": "^1.3.0", - "postgres-interval": "^4.0.2", - "roarr": "^7.21.1", - "serialize-error": "^8.0.0", - "strict-event-emitter-types": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3" - } - }, - "node_modules/slonik-interceptor-query-logging": { - "version": "46.8.0", - "resolved": "https://registry.npmjs.org/slonik-interceptor-query-logging/-/slonik-interceptor-query-logging-46.8.0.tgz", - "integrity": "sha512-Aat1fzSKT8tNIX/46D/58DPBRfZCyat2GvmUXZPZAb/l94JYnC+7s/fa79bbXEAWXSjaajTl5VazM10wFg8UgA==", - "license": "BSD-3-Clause", - "dependencies": { - "crack-json": "^1.3.0", - "pretty-ms": "^7.0.1", - "serialize-error": "^8.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "slonik": ">=45.0.0" - } - }, - "node_modules/slonik/node_modules/postgres-interval": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-4.0.2.tgz", - "integrity": "sha512-EMsphSQ1YkQqKZL2cuG0zHkmjCCzQqQ71l2GXITqRwjhRleCdv00bDk/ktaSi0LnlaPzAc3535KTrjXsTdtx7A==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", - "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", - "dev": true, - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sql-template-strings": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/sql-template-strings/-/sql-template-strings-2.2.2.tgz", - "integrity": "sha512-UXhXR2869FQaD+GMly8jAMCRZ94nU5KcrFetZfWEMd+LVVG6y0ExgHAhatEcKZ/wk8YcKPdi+hiD2wm75lq3/Q==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/stacktrace-parser": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", - "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/stacktrace-parser/node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strict-event-emitter-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", - "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", - "license": "ISC" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", - "dev": true, - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/toad-cache": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", - "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/slonik/package.json b/packages/slonik/package.json index 7dfd12652..121c3b604 100644 --- a/packages/slonik/package.json +++ b/packages/slonik/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-slonik", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify slonik plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/slonik#readme", "repository": { @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-slonik.cjs", "module": "./dist/prefabs-tech-fastify-slonik.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -31,34 +33,34 @@ "dependencies": { "@prefabs.tech/postgres-migrations": "5.4.3", "humps": "2.0.1", - "pg": "8.18.0", + "pg": "8.20.0", "slonik-interceptor-query-logging": "46.8.0" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/tsconfig": "0.5.0", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/tsconfig": "0.7.0", "@slonik/driver": "46.8.0", "@types/humps": "2.0.6", - "@types/node": "24.10.13", - "@types/pg": "8.16.0", + "@types/node": "24.10.15", + "@types/pg": "8.20.0", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "pg-mem": "3.0.12", - "prettier": "3.8.1", + "pg-mem": "3.0.14", + "prettier": "3.8.3", "slonik": "46.8.0", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4", "zod": "3.25.76" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.5", - "fastify": ">=5.2.1", + "@prefabs.tech/fastify-config": "0.94.0", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1", - "pg-mem": ">=3.0.2", + "pg-mem": ">=3.0.14", "slonik": ">=46.1.0", "zod": ">=3.23.8" }, @@ -70,4 +72,4 @@ "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/slonik/src/__test__/filters.test.ts b/packages/slonik/src/__test__/filters.test.ts index 3c347dc37..572559d2e 100644 --- a/packages/slonik/src/__test__/filters.test.ts +++ b/packages/slonik/src/__test__/filters.test.ts @@ -1,62 +1,70 @@ +import type { IdentifierSqlToken } from "slonik"; + import { sql } from "slonik"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; -import { applyFiltersToQuery, applyFilter } from "../filters"; +import type { BaseFilterInput, FilterInput } from "../types"; -import type { FilterInput, BaseFilterInput } from "../types"; -import type { IdentifierSqlToken } from "slonik"; +import { + applyFilter, + applyFiltersToQuery, + buildFilterFragment, +} from "../filters"; // Comprehensive dataset of filter combinations for testing const getFilterDataset = (): Array<{ - name: string; - filter: FilterInput; + description: string; expectedSQL: RegExp; expectedValues: string[]; - description: string; + filter: FilterInput; + name: string; }> => { return [ { - name: "Simple equality filter", - filter: { key: "name", operator: "eq", value: "test" }, + description: "Basic equality operation", expectedSQL: /WHERE "users"\."name" = \$slonik_\d+$/, expectedValues: ["test"], - description: "Basic equality operation", + filter: { key: "name", operator: "eq", value: "test" }, + name: "Simple equality filter", }, { - name: "Simple starts with filter", - filter: { key: "name", operator: "sw", value: "test" }, + description: "Starts with operation", expectedSQL: /WHERE "users"\."name" ILIKE \$slonik_\d+$/, expectedValues: ["test%"], - description: "Starts with operation", + filter: { key: "name", operator: "sw", value: "test" }, + name: "Simple starts with filter", }, { - name: "Simple AND operation", + description: "Simple AND with two conditions", + expectedSQL: + /WHERE \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" > \$slonik_\d+\)/, + expectedValues: ["test%", "25"], filter: { AND: [ { key: "name", operator: "sw", value: "test" }, { key: "age", operator: "gt", value: "25" }, ], }, - expectedSQL: - /WHERE \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" > \$slonik_\d+\)/, - expectedValues: ["test%", "25"], - description: "Simple AND with two conditions", + name: "Simple AND operation", }, { - name: "Simple OR operation", + description: "Simple OR with two conditions", + expectedSQL: + /WHERE \("users"\."name" ILIKE \$slonik_\d+ OR "users"\."name" ILIKE \$slonik_\d+\)/, + expectedValues: ["Test%", "%t1"], filter: { OR: [ { key: "name", operator: "sw", value: "Test" }, { key: "name", operator: "ew", value: "t1" }, ], }, - expectedSQL: - /WHERE \("users"\."name" ILIKE \$slonik_\d+ OR "users"\."name" ILIKE \$slonik_\d+\)/, - expectedValues: ["Test%", "%t1"], - description: "Simple OR with two conditions", + name: "Simple OR operation", }, { - name: "AND with nested OR", + description: "AND containing OR - tests proper nesting", + expectedSQL: + /WHERE \("users"\."id" > \$slonik_\d+ AND \("users"\."name" ILIKE \$slonik_\d+ OR "users"\."name" ILIKE \$slonik_\d+\)\)/, + expectedValues: ["10", "Test%", "%t1"], filter: { AND: [ { key: "id", operator: "gt", value: "10" }, @@ -68,13 +76,13 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \("users"\."id" > \$slonik_\d+ AND \("users"\."name" ILIKE \$slonik_\d+ OR "users"\."name" ILIKE \$slonik_\d+\)\)/, - expectedValues: ["10", "Test%", "%t1"], - description: "AND containing OR - tests proper nesting", + name: "AND with nested OR", }, { - name: "OR with nested AND", + description: "OR containing AND - tests proper nesting", + expectedSQL: + /WHERE \("users"\."id" > \$slonik_\d+ OR \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."name" ILIKE \$slonik_\d+\)\)/, + expectedValues: ["10", "Test%", "%t1"], filter: { OR: [ { key: "id", operator: "gt", value: "10" }, @@ -86,13 +94,13 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \("users"\."id" > \$slonik_\d+ OR \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."name" ILIKE \$slonik_\d+\)\)/, - expectedValues: ["10", "Test%", "%t1"], - description: "OR containing AND - tests proper nesting", + name: "OR with nested AND", }, { - name: "OR with multiple AND blocks", + description: "OR with multiple AND blocks - tests complex grouping", + expectedSQL: + /WHERE \(\("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" > \$slonik_\d+\) OR \("users"\."email" ILIKE \$slonik_\d+ AND "users"\."status" = \$slonik_\d+\)\)/, + expectedValues: ["Test%", "25", "%@test.com", "active"], filter: { OR: [ { @@ -109,13 +117,13 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \(\("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" > \$slonik_\d+\) OR \("users"\."email" ILIKE \$slonik_\d+ AND "users"\."status" = \$slonik_\d+\)\)/, - expectedValues: ["Test%", "25", "%@test.com", "active"], - description: "OR with multiple AND blocks - tests complex grouping", + name: "OR with multiple AND blocks", }, { - name: "Complex nested structure", + description: "Deep nesting: OR[AND[condition, OR[...]], AND[...]]", + expectedSQL: + /WHERE \(\("users"\."name" ILIKE \$slonik_\d+ AND \("users"\."department" = \$slonik_\d+ OR "users"\."department" = \$slonik_\d+\)\) OR \("users"\."role" = \$slonik_\d+ AND "users"\."verified" = \$slonik_\d+\)\)/, + expectedValues: ["Test%", "engineering", "design", "admin", "true"], filter: { OR: [ { @@ -137,13 +145,13 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \(\("users"\."name" ILIKE \$slonik_\d+ AND \("users"\."department" = \$slonik_\d+ OR "users"\."department" = \$slonik_\d+\)\) OR \("users"\."role" = \$slonik_\d+ AND "users"\."verified" = \$slonik_\d+\)\)/, - expectedValues: ["Test%", "engineering", "design", "admin", "true"], - description: "Deep nesting: OR[AND[condition, OR[...]], AND[...]]", + name: "Complex nested structure", }, { - name: "Triple nested structure", + description: "Triple nesting: AND[condition, OR[AND[...], OR[...]]]", + expectedSQL: + /WHERE \("users"\."status" = \$slonik_\d+ AND \(\("users"\."age" >= \$slonik_\d+ AND "users"\."age" <= \$slonik_\d+\) OR \("users"\."role" = \$slonik_\d+ OR "users"\."special" = \$slonik_\d+\)\)\)/, + expectedValues: ["active", "18", "65", "admin", "true"], filter: { AND: [ { key: "status", operator: "eq", value: "active" }, @@ -165,31 +173,31 @@ const getFilterDataset = (): Array<{ }, ], }, - expectedSQL: - /WHERE \("users"\."status" = \$slonik_\d+ AND \(\("users"\."age" >= \$slonik_\d+ AND "users"\."age" <= \$slonik_\d+\) OR \("users"\."role" = \$slonik_\d+ OR "users"\."special" = \$slonik_\d+\)\)\)/, - expectedValues: ["active", "18", "65", "admin", "true"], - description: "Triple nesting: AND[condition, OR[AND[...], OR[...]]]", + name: "Triple nested structure", }, { - name: "Single condition in AND array", + description: "Single condition should not have extra parentheses", + expectedSQL: /WHERE "users"\."name" = \$slonik_\d+$/, + expectedValues: ["test"], filter: { AND: [{ key: "name", operator: "eq", value: "test" }], }, - expectedSQL: /WHERE "users"\."name" = \$slonik_\d+$/, - expectedValues: ["test"], - description: "Single condition should not have extra parentheses", + name: "Single condition in AND array", }, { - name: "Single condition in OR array", + description: "Single condition should not have extra parentheses", + expectedSQL: /WHERE "users"\."status" = \$slonik_\d+$/, + expectedValues: ["active"], filter: { OR: [{ key: "status", operator: "eq", value: "active" }], }, - expectedSQL: /WHERE "users"\."status" = \$slonik_\d+$/, - expectedValues: ["active"], - description: "Single condition should not have extra parentheses", + name: "Single condition in OR array", }, { - name: "Multiple operators test", + description: "Tests all different operators", + expectedSQL: + /WHERE \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" BETWEEN \$slonik_\d+ AND \$slonik_\d+ AND "users"\."status" IN \(\$slonik_\d+, \$slonik_\d+\) AND "users"\."deleted_at" IS NULL\)/, + expectedValues: ["%test%", "25", "65", "active", "pending"], filter: { AND: [ { key: "name", operator: "ct", value: "test" }, @@ -198,28 +206,25 @@ const getFilterDataset = (): Array<{ { key: "deletedAt", operator: "eq", value: "null" }, ], }, - expectedSQL: - /WHERE \("users"\."name" ILIKE \$slonik_\d+ AND "users"\."age" BETWEEN \$slonik_\d+ AND \$slonik_\d+ AND "users"\."status" IN \(\$slonik_\d+, \$slonik_\d+\) AND "users"\."deleted_at" IS NULL\)/, - expectedValues: ["%test%", "25", "65", "active", "pending"], - description: "Tests all different operators", + name: "Multiple operators test", }, { - name: "NOT flag operations", + description: "Tests NOT flag with different operators", + expectedSQL: + /WHERE \("users"\."name" != \$slonik_\d+ AND "users"\."status" NOT IN \(\$slonik_\d+, \$slonik_\d+\)\)/, + expectedValues: ["test", "inactive", "banned"], filter: { AND: [ - { key: "name", operator: "eq", value: "test", not: true }, + { key: "name", not: true, operator: "eq", value: "test" }, { key: "status", + not: true, operator: "in", value: "inactive,banned", - not: true, }, ], }, - expectedSQL: - /WHERE \("users"\."name" != \$slonik_\d+ AND "users"\."status" NOT IN \(\$slonik_\d+, \$slonik_\d+\)\)/, - expectedValues: ["test", "inactive", "banned"], - description: "Tests NOT flag with different operators", + name: "NOT flag operations", }, ]; }; @@ -248,9 +253,9 @@ describe("dbFilters", () => { it("should handle equality operator with not flag", () => { const filter: BaseFilterInput = { key: "name", + not: true, operator: "eq", value: "John", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -275,9 +280,9 @@ describe("dbFilters", () => { it("should handle null values with not flag", () => { const filter: BaseFilterInput = { key: "deletedAt", + not: true, operator: "eq", value: "NULL", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -486,9 +491,9 @@ describe("dbFilters", () => { it("should handle join table key with NOT flag", () => { const filter: BaseFilterInput = { key: "posts.status", + not: true, operator: "in", value: "draft,archived", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -513,9 +518,9 @@ describe("dbFilters", () => { it("should handle join table key with null values and NOT flag", () => { const filter: BaseFilterInput = { key: "comments.deletedAt", + not: true, operator: "eq", value: "NULL", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -529,10 +534,10 @@ describe("dbFilters", () => { describe("applyFilter > case insensitive", () => { it("should handle equality operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "eq", value: "John", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -545,11 +550,11 @@ describe("dbFilters", () => { it("should handle equality operator with not flag", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", + not: true, operator: "eq", value: "John", - not: true, - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -562,10 +567,10 @@ describe("dbFilters", () => { it("should handle null values", () => { const filter: BaseFilterInput = { + insensitive: true, key: "deletedAt", operator: "eq", value: "null", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -576,11 +581,11 @@ describe("dbFilters", () => { it("should handle null values with not flag", () => { const filter: BaseFilterInput = { + insensitive: true, key: "deletedAt", + not: true, operator: "eq", value: "NULL", - not: true, - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -591,10 +596,10 @@ describe("dbFilters", () => { it("should handle contains operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "ct", value: "John", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -607,10 +612,10 @@ describe("dbFilters", () => { it("should handle starts with operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "sw", value: "John", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -623,10 +628,10 @@ describe("dbFilters", () => { it("should handle ends with operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "ew", value: "son", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -639,10 +644,10 @@ describe("dbFilters", () => { it("should handle greater than operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "gt", value: "25", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -655,10 +660,10 @@ describe("dbFilters", () => { it("should handle greater than or equal operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "gte", value: "25", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -671,10 +676,10 @@ describe("dbFilters", () => { it("should handle less than operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "lt", value: "65", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -687,10 +692,10 @@ describe("dbFilters", () => { it("should handle less than or equal operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "lte", value: "65", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -703,10 +708,10 @@ describe("dbFilters", () => { it("should handle in operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "status", operator: "in", value: "active,inactive,pending", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -719,10 +724,10 @@ describe("dbFilters", () => { it("should handle between operator", () => { const filter: BaseFilterInput = { + insensitive: true, key: "age", operator: "bt", value: "25,65", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -735,10 +740,10 @@ describe("dbFilters", () => { it("should convert camelCase keys to snake_case", () => { const filter: BaseFilterInput = { + insensitive: true, key: "firstName", operator: "eq", value: "John", - insensitive: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -751,10 +756,10 @@ describe("dbFilters", () => { it("should handle schema.table identifiers", () => { const filter: BaseFilterInput = { + insensitive: true, key: "name", operator: "eq", value: "John", - insensitive: true, }; const result = applyFilter(mockSchemaTableIdentifier, filter); @@ -822,9 +827,9 @@ describe("dbFilters", () => { it("should handle join table key with NOT flag", () => { const filter: BaseFilterInput = { key: "posts.status", + not: true, operator: "in", value: "draft,archived", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -849,9 +854,9 @@ describe("dbFilters", () => { it("should handle join table key with null values and NOT flag", () => { const filter: BaseFilterInput = { key: "comments.deletedAt", + not: true, operator: "eq", value: "NULL", - not: true, }; const result = applyFilter(mockTableIdentifier, filter); @@ -866,8 +871,8 @@ describe("dbFilters", () => { it("should default to eq operator if not provided", () => { const filter = { key: "name", - value: "John", operator: "eq", + value: "John", } as BaseFilterInput; const result = applyFilter(mockTableIdentifier, filter); @@ -890,9 +895,9 @@ describe("dbFilters", () => { it("should throw error for empty IN list with NOT", () => { const filter: BaseFilterInput = { key: "status", + not: true, operator: "in", value: "", - not: true, }; expect(() => applyFilter(mockTableIdentifier, filter)).toThrow( @@ -1195,17 +1200,17 @@ describe("dbFilters", () => { AND: [ { key: "posts.status", + not: true, operator: "eq", value: "published", - not: true, }, { key: "comments.isSpam", + not: true, operator: "eq", value: "true", - not: true, }, - { key: "tags.isHidden", operator: "eq", value: "null", not: true }, + { key: "tags.isHidden", not: true, operator: "eq", value: "null" }, ], }; @@ -1273,3 +1278,87 @@ describe("dbFilters", () => { }); }); }); + +describe("applyFilter — dwithin operator", () => { + const mockTableIdentifier: IdentifierSqlToken = sql.identifier(["locations"]); + + it("generates ST_DWithin SQL with lat, lng, radius from value string", () => { + const filter: BaseFilterInput = { + key: "coordinates", + operator: "dwithin", + value: "48.8566,2.3522,1000", + }; + + const result = applyFilter(mockTableIdentifier, filter); + + expect(result.sql).toMatch(/ST_DWithin/); + expect(result.sql).toMatch(/ST_SetSRID/); + expect(result.sql).toMatch(/ST_MakePoint/); + }); + + it("places longitude before latitude in ST_MakePoint (GeoJSON order)", () => { + const filter: BaseFilterInput = { + key: "coordinates", + operator: "dwithin", + value: "48.8566,2.3522,500", + }; + + const result = applyFilter(mockTableIdentifier, filter); + + // ST_MakePoint(longitude, latitude) — values[0]=lng=2.3522, values[1]=lat=48.8566 + expect(result.values).toContain("2.3522"); + expect(result.values).toContain("48.8566"); + }); + + it("includes the radius value", () => { + const filter: BaseFilterInput = { + key: "coordinates", + operator: "dwithin", + value: "40.7128,-74.0060,5000", + }; + + const result = applyFilter(mockTableIdentifier, filter); + expect(result.values).toContain("5000"); + }); +}); + +describe("buildFilterFragment — empty input paths", () => { + const mockTableIdentifier: IdentifierSqlToken = sql.identifier(["users"]); + + it("returns undefined for empty AND array", () => { + const result = buildFilterFragment({ AND: [] }, mockTableIdentifier); + expect(result).toBeUndefined(); + }); + + it("returns undefined for empty OR array", () => { + const result = buildFilterFragment({ OR: [] }, mockTableIdentifier); + expect(result).toBeUndefined(); + }); + + it("returns single fragment directly when AND has one item (no extra parens)", () => { + const result = buildFilterFragment( + { AND: [{ key: "name", operator: "eq", value: "alice" }] }, + mockTableIdentifier, + ); + expect(result?.sql).not.toMatch(/^\(/); + expect(result?.sql).toMatch(/"users"\."name"/); + }); + + it("returns single fragment directly when OR has one item (no extra parens)", () => { + const result = buildFilterFragment( + { OR: [{ key: "name", operator: "eq", value: "alice" }] }, + mockTableIdentifier, + ); + expect(result?.sql).not.toMatch(/^\(/); + expect(result?.sql).toMatch(/"users"\."name"/); + }); + + it("returns undefined for null/undefined filter", () => { + const result = buildFilterFragment( + // eslint-disable-next-line unicorn/no-null + null as unknown as FilterInput, + mockTableIdentifier, + ); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/slonik/src/__test__/helpers/createConfig.ts b/packages/slonik/src/__test__/helpers/createConfig.ts index 3e379d488..165aff9f8 100644 --- a/packages/slonik/src/__test__/helpers/createConfig.ts +++ b/packages/slonik/src/__test__/helpers/createConfig.ts @@ -1,6 +1,7 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + /* istanbul ignore file */ import type { SlonikOptions } from "../../types"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; const createConfig = (slonikOptions?: SlonikOptions) => { const config: ApiConfig = { @@ -17,7 +18,6 @@ const createConfig = (slonikOptions?: SlonikOptions) => { rest: { enabled: true, }, - version: "0.1", slonik: { db: { databaseName: "test", @@ -27,6 +27,7 @@ const createConfig = (slonikOptions?: SlonikOptions) => { }, ...slonikOptions, }, + version: "0.1", }; return config; diff --git a/packages/slonik/src/__test__/helpers/createDatabase.ts b/packages/slonik/src/__test__/helpers/createDatabase.ts index 185b6ac6c..342eb16fe 100644 --- a/packages/slonik/src/__test__/helpers/createDatabase.ts +++ b/packages/slonik/src/__test__/helpers/createDatabase.ts @@ -1,10 +1,10 @@ +import type { IMemoryDb, SlonikAdapterOptions } from "pg-mem"; + /* istanbul ignore file */ import { newDb } from "pg-mem"; import fieldNameCaseConverter from "../../interceptors/fieldNameCaseConverter"; -import type { SlonikAdapterOptions, IMemoryDb } from "pg-mem"; - interface IOptions { db?: IMemoryDb; slonikAdapterOptions?: SlonikAdapterOptions; diff --git a/packages/slonik/src/__test__/helpers/testService.ts b/packages/slonik/src/__test__/helpers/testService.ts index 9901c2a67..e43a5bda5 100644 --- a/packages/slonik/src/__test__/helpers/testService.ts +++ b/packages/slonik/src/__test__/helpers/testService.ts @@ -1,8 +1,8 @@ -import TestSqlFactory from "./sqlFactory"; -import BaseService from "../../service"; - import type { QueryResultRow } from "slonik"; +import BaseService from "../../service"; +import TestSqlFactory from "./sqlFactory"; + class TestService< T extends QueryResultRow, C extends QueryResultRow, diff --git a/packages/slonik/src/__test__/helpers/utils.ts b/packages/slonik/src/__test__/helpers/utils.ts index 548ace2a7..b008593eb 100644 --- a/packages/slonik/src/__test__/helpers/utils.ts +++ b/packages/slonik/src/__test__/helpers/utils.ts @@ -105,13 +105,13 @@ const getLimitAndOffsetDataset = async (count: number, config: ApiConfig) => { const getSortDataset = (): SortInput[][] => { return [ - [{ key: "name", direction: "ASC" }], - [{ key: "id", direction: "DESC" }], - [{ key: "countryCode", direction: "ASC" }], - [{ key: "country_code", direction: "DESC" }], + [{ direction: "ASC", key: "name" }], + [{ direction: "DESC", key: "id" }], + [{ direction: "ASC", key: "countryCode" }], + [{ direction: "DESC", key: "country_code" }], [ - { key: "id", direction: "DESC" }, - { key: "name", direction: "ASC" }, + { direction: "DESC", key: "id" }, + { direction: "ASC", key: "name" }, ], ]; }; diff --git a/packages/slonik/src/__test__/migrate.test.ts b/packages/slonik/src/__test__/migrate.test.ts new file mode 100644 index 000000000..531e2e779 --- /dev/null +++ b/packages/slonik/src/__test__/migrate.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SlonikOptions } from "../types"; + +const pgClientConstructorMock = vi.fn(); +const pgClientConnectMock = vi.fn().mockResolvedValue(); +const pgClientEndMock = vi.fn().mockResolvedValue(); + +vi.mock("pg", () => { + const Client = pgClientConstructorMock.mockImplementation(() => ({ + connect: pgClientConnectMock, + end: pgClientEndMock, + query: vi.fn().mockResolvedValue({ rows: [] }), + })); + return { Client, default: { Client } }; +}); + +vi.mock("@prefabs.tech/postgres-migrations", () => ({ + migrate: vi.fn().mockResolvedValue(), +})); + +const baseOptions: SlonikOptions = { + db: { + databaseName: "testdb", + host: "localhost", + password: "pass", + username: "user", + }, +}; + +describe("migrate — default migration path", async () => { + const { default: migrate } = await import("../migrate"); + const { migrate: runMigrationsMock } = + await import("@prefabs.tech/postgres-migrations"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses 'migrations' as default path when options.migrations.path is not set", async () => { + await migrate(baseOptions); + expect(runMigrationsMock).toHaveBeenCalledWith( + expect.any(Object), + "migrations", + ); + }); + + it("uses provided path when options.migrations.path is set", async () => { + await migrate({ ...baseOptions, migrations: { path: "build/migrations" } }); + expect(runMigrationsMock).toHaveBeenCalledWith( + expect.any(Object), + "build/migrations", + ); + }); + + it("passes db credentials to pg.Client", async () => { + await migrate(baseOptions); + expect(pgClientConstructorMock).toHaveBeenCalledWith( + expect.objectContaining({ + database: "testdb", + host: "localhost", + password: "pass", + user: "user", + }), + ); + }); + + it("includes ssl in pg.Client config when clientConfiguration.ssl is set", async () => { + const ssl = { rejectUnauthorized: false }; + await migrate({ + ...baseOptions, + clientConfiguration: { ssl } as never, + }); + expect(pgClientConstructorMock).toHaveBeenCalledWith( + expect.objectContaining({ ssl }), + ); + }); + + it("does not include ssl in pg.Client config when clientConfiguration.ssl is not set", async () => { + await migrate(baseOptions); + const callArgument = pgClientConstructorMock.mock.calls[0][0]; + expect(callArgument).not.toHaveProperty("ssl"); + }); + + it("connects and ends the pg client", async () => { + await migrate(baseOptions); + expect(pgClientConnectMock).toHaveBeenCalledOnce(); + expect(pgClientEndMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/slonik/src/__test__/migrationPlugin.test.ts b/packages/slonik/src/__test__/migrationPlugin.test.ts new file mode 100644 index 000000000..82c7c81db --- /dev/null +++ b/packages/slonik/src/__test__/migrationPlugin.test.ts @@ -0,0 +1,66 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SlonikOptions } from "../types"; + +const migrateMock = vi.fn().mockResolvedValue(); + +vi.mock("../migrate", () => ({ + default: migrateMock, +})); + +const baseOptions: SlonikOptions = { + db: { + databaseName: "test", + host: "localhost", + password: "pass", + username: "user", + }, +}; + +describe("migrationPlugin — registration", async () => { + const { default: plugin } = await import("../migrationPlugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + it("registers without throwing", async () => { + await expect(fastify.register(plugin, baseOptions)).resolves.not.toThrow(); + }); + + it("calls migrate with the provided options", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(migrateMock).toHaveBeenCalledWith(baseOptions); + }); +}); + +describe("migrationPlugin — legacy config fallback", async () => { + const { default: plugin } = await import("../migrationPlugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + it("reads options from fastify.config.slonik when no options passed", async () => { + fastify.decorate("config", { slonik: baseOptions }); + await fastify.register(plugin); + await fastify.ready(); + expect(migrateMock).toHaveBeenCalledWith(baseOptions); + }); + + it("throws descriptive error when no options and no fastify.config.slonik", async () => { + await expect(fastify.register(plugin)).rejects.toThrow( + "Missing migration configuration. Did you forget to pass it to the migration plugin?", + ); + }); +}); diff --git a/packages/slonik/src/__test__/plugin.test.ts b/packages/slonik/src/__test__/plugin.test.ts new file mode 100644 index 000000000..926863093 --- /dev/null +++ b/packages/slonik/src/__test__/plugin.test.ts @@ -0,0 +1,192 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SlonikOptions } from "../types"; + +const runMigrationsMock = vi.fn().mockResolvedValue(); +const stringifyDsnMock = vi + .fn() + .mockReturnValue("postgresql://user:pass@localhost/test"); +const createClientConfigurationMock = vi + .fn() + .mockReturnValue({ interceptors: [] }); + +// fastifySlonik must be wrapped with fastify-plugin so its decorations escape +// the child scope and reach the parent fastify instance. +vi.mock("../slonik", async () => { + const { default: FastifyPlugin } = await import("fastify-plugin"); + + const fakeSlonik = { + connect: vi.fn(), + pool: {}, + query: vi.fn(), + }; + + return { + fastifySlonik: FastifyPlugin(async (fastify: FastifyInstance) => { + if (!fastify.hasDecorator("slonik")) + fastify.decorate("slonik", fakeSlonik); + if (!fastify.hasDecorator("sql")) fastify.decorate("sql", {}); + if (!fastify.hasRequestDecorator("slonik")) + fastify.decorateRequest("slonik"); + if (!fastify.hasRequestDecorator("sql")) fastify.decorateRequest("sql"); + }), + }; +}); + +vi.mock("../migrations/runMigrations", () => ({ + default: runMigrationsMock, +})); + +vi.mock("slonik", () => ({ + stringifyDsn: stringifyDsnMock, +})); + +vi.mock("../factories/createClientConfiguration", () => ({ + default: createClientConfigurationMock, +})); + +const baseOptions: SlonikOptions = { + db: { + databaseName: "test", + host: "localhost", + password: "pass", + username: "user", + }, +}; + +describe("slonikPlugin — registration", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + it("registers without throwing", async () => { + await expect(fastify.register(plugin, baseOptions)).resolves.not.toThrow(); + }); + + it("decorates fastify.slonik after registration", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(fastify.slonik).toBeDefined(); + }); + + it("decorates fastify.sql after registration", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(fastify.sql).toBeDefined(); + }); + + it("decorates req.dbSchema as empty string in route handlers", async () => { + await fastify.register(plugin, baseOptions); + + fastify.get("/test", async (req) => { + return { dbSchema: req.dbSchema }; + }); + + const res = await fastify.inject({ method: "GET", url: "/test" }); + expect(res.json().dbSchema).toBe(""); + }); + + it("calls stringifyDsn with options.db", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(stringifyDsnMock).toHaveBeenCalledWith(baseOptions.db); + }); + + it("calls createClientConfiguration with clientConfiguration and queryLogging.enabled", async () => { + const options: SlonikOptions = { + ...baseOptions, + queryLogging: { enabled: true }, + }; + await fastify.register(plugin, options); + await fastify.ready(); + expect(createClientConfigurationMock).toHaveBeenCalledWith( + options.clientConfiguration, + true, + ); + }); + + it("passes undefined for queryLogging.enabled when queryLogging is not set", async () => { + await fastify.register(plugin, baseOptions); + await fastify.ready(); + expect(createClientConfigurationMock).toHaveBeenCalledWith( + undefined, + undefined, + ); + }); + + it("passes false for queryLogging.enabled when query logging is explicitly disabled", async () => { + const options: SlonikOptions = { + ...baseOptions, + queryLogging: { enabled: false }, + }; + await fastify.register(plugin, options); + await fastify.ready(); + expect(createClientConfigurationMock).toHaveBeenCalledWith( + options.clientConfiguration, + false, + ); + }); + + it("runs extension and migration setup after the pool is decorated", async () => { + const options: SlonikOptions = { + ...baseOptions, + extensions: ["uuid-ossp"], + }; + await fastify.register(plugin, options); + await fastify.ready(); + expect(runMigrationsMock).toHaveBeenCalledTimes(1); + expect(runMigrationsMock).toHaveBeenCalledWith(fastify.slonik, options); + }); +}); + +describe("slonikPlugin — legacy config fallback", async () => { + const { default: plugin } = await import("../plugin"); + + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + it("reads options from fastify.config.slonik when no options passed", async () => { + const warnSpy = vi.spyOn(fastify.log, "warn").mockImplementation(() => {}); + fastify.decorate("config", { slonik: baseOptions }); + await fastify.register(plugin); + await fastify.ready(); + expect(stringifyDsnMock).toHaveBeenCalledWith(baseOptions.db); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "The slonik plugin now recommends passing slonik options directly", + ), + ); + }); + + it("fastify.slonik is available after legacy registration", async () => { + fastify.decorate("config", { slonik: baseOptions }); + await fastify.register(plugin); + await fastify.ready(); + expect(fastify.slonik).toBeDefined(); + }); + + it("throws descriptive error when no options and no fastify.config.slonik", async () => { + await expect(fastify.register(plugin)).rejects.toThrow( + "Missing slonik configuration. Did you forget to pass it to the slonik plugin?", + ); + }); + + it("runs extension setup with config resolved from fastify.config.slonik", async () => { + fastify.decorate("config", { slonik: baseOptions }); + await fastify.register(plugin); + await fastify.ready(); + expect(runMigrationsMock).toHaveBeenCalledWith(fastify.slonik, baseOptions); + }); +}); diff --git a/packages/slonik/src/__test__/service.test.ts b/packages/slonik/src/__test__/service.test.ts index 8a549103f..bbe7bb96e 100644 --- a/packages/slonik/src/__test__/service.test.ts +++ b/packages/slonik/src/__test__/service.test.ts @@ -2,6 +2,8 @@ import { newDb } from "pg-mem"; import { describe, expect, it } from "vitest"; +import type { SlonikOptions } from "../types"; + import createConfig from "./helpers/createConfig"; import createDatabase from "./helpers/createDatabase"; import TestSqlFactory from "./helpers/sqlFactory"; @@ -12,8 +14,6 @@ import { getSortDataset, } from "./helpers/utils"; -import type { SlonikOptions } from "../types"; - describe("Service", async () => { const db = newDb(); @@ -111,8 +111,8 @@ describe("Service", async () => { it("calls database with correct sql query for find method", async () => { const result = [ - { id: 1, name: "Test1", countryCode: "NP", latitude: 20 }, - { id: 2, name: "Test2", countryCode: "US", latitude: 30 }, + { countryCode: "NP", id: 1, latitude: 20, name: "Test1" }, + { countryCode: "US", id: 2, latitude: 30, name: "Test2" }, ]; const service = new TestService(config, database); @@ -123,24 +123,24 @@ describe("Service", async () => { }); it("calls database with correct sql query for findOne method", async () => { - const result = { id: 1, name: "Test1", countryCode: "NP", latitude: 20 }; + const result = { countryCode: "NP", id: 1, latitude: 20, name: "Test1" }; const service = new TestService(config, database); const response = await service.findOne( { key: "id", operator: "eq", value: "1" }, - [{ key: "id", direction: "ASC" }], + [{ direction: "ASC", key: "id" }], ); expect(response).toStrictEqual(result); }); it("calls database with correct sql query for create method", async () => { - const result = [{ id: 3, name: "Test", latitude: 20, countryCode: "FR" }]; + const result = [{ countryCode: "FR", id: 3, latitude: 20, name: "Test" }]; const service = new TestService(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const response = await service.create(data); @@ -150,7 +150,7 @@ describe("Service", async () => { it("calls database with correct sql query for findById method", async () => { const data = 1; - const result = [{ id: 1, name: "Test1", latitude: 20, countryCode: "NP" }]; + const result = [{ countryCode: "NP", id: 1, latitude: 20, name: "Test1" }]; const service = new TestService(config, database); @@ -162,7 +162,7 @@ describe("Service", async () => { it("calls database with correct sql query for update method", async () => { const data = { name: "updated test" }; - const result = [{ id: 1, ...data, latitude: 20, countryCode: "NP" }]; + const result = [{ id: 1, ...data, countryCode: "NP", latitude: 20 }]; const service = new TestService(config, database); @@ -174,7 +174,7 @@ describe("Service", async () => { it("calls database with correct sql query for delete method", async () => { const id = 3; - const result = [{ id: 3, name: "Test", latitude: 20, countryCode: "FR" }]; + const result = [{ countryCode: "FR", id: 3, latitude: 20, name: "Test" }]; const service = new TestService(config, database); @@ -264,3 +264,45 @@ describe("Service", async () => { } }); }); + +describe("BaseService — isCompatibleType edge cases", async () => { + const db = newDb(); + db.public.none( + `create table test(id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT, latitude INT, country_code TEXT); + insert into test (name, latitude, country_code) values ('Edge1', 10, 'DE');`, + ); + const config = createConfig(); + const database = await createDatabase({ db }); + + class InspectService extends TestService< + Record, + Record, + Record + > { + // Expose protected method for testing + testIsCompatibleType(processed: T, original: T): boolean { + return this.isCompatibleType(processed, original); + } + } + + it("returns true when original is undefined (processed can be anything)", () => { + const service = new InspectService(config, database); + expect(service.testIsCompatibleType("anything")).toBe(true); + }); + + it("returns false when processed is array but original is plain object", () => { + const service = new InspectService(config, database); + expect(service.testIsCompatibleType([], {})).toBe(false); + }); + + it("returns false when processed is object but original is array", () => { + const service = new InspectService(config, database); + expect(service.testIsCompatibleType({}, [])).toBe(false); + }); + + it("returns false when null processed does not match non-null original", () => { + const service = new InspectService(config, database); + // eslint-disable-next-line unicorn/no-null + expect(service.testIsCompatibleType(null, {})).toBe(false); + }); +}); diff --git a/packages/slonik/src/__test__/serviceWithHooks.test.ts b/packages/slonik/src/__test__/serviceWithHooks.test.ts index 0913cdbd4..fcbe59016 100644 --- a/packages/slonik/src/__test__/serviceWithHooks.test.ts +++ b/packages/slonik/src/__test__/serviceWithHooks.test.ts @@ -1,57 +1,21 @@ +import type { QueryResultRow } from "slonik"; + /* istanbul ignore file */ import { newDb } from "pg-mem"; import { describe, expect, it } from "vitest"; +import type { PaginatedList } from "../types"; + import createConfig from "./helpers/createConfig"; import createDatabase from "./helpers/createDatabase"; import TestService from "./helpers/testService"; -import type { PaginatedList } from "../types"; -import type { QueryResultRow } from "slonik"; - // Test service with hooks implementation class TestServiceWithHooks< T extends QueryResultRow, C extends QueryResultRow, U extends QueryResultRow, > extends TestService { - // Pre-hooks - async preAll() { - // No processing needed for read operations - } - - async preCount() { - // No processing needed for read operations - } - - async preCreate(data: C): Promise { - return { ...data, name: `pre-${data.name}` } as C; - } - - async preDelete() { - // No processing needed for delete operation - } - - async preFind() { - // No processing needed for read operations - } - - async preFindById() { - // No processing needed for read operations - } - - async preFindOne() { - // No processing needed for read operations - } - - async preList() { - // No processing needed for read operations - } - - async preUpdate(data: U): Promise { - return { ...data, name: `pre-${data.name}` } as U; - } - // Post-hooks async postAll(result: Partial): Promise> { return result.map((item) => ({ @@ -97,6 +61,43 @@ class TestServiceWithHooks< async postUpdate(result: T): Promise { return { ...result, processed: "updated" } as T; } + + // Pre-hooks + async preAll() { + // No processing needed for read operations + } + + async preCount() { + // No processing needed for read operations + } + + async preCreate(data: C): Promise { + return { ...data, name: `pre-${data.name}` } as C; + } + + async preDelete() { + // No processing needed for delete operation + } + + async preFind() { + // No processing needed for read operations + } + + async preFindById() { + // No processing needed for read operations + } + + async preFindOne() { + // No processing needed for read operations + } + + async preList() { + // No processing needed for read operations + } + + async preUpdate(data: U): Promise { + return { ...data, name: `pre-${data.name}` } as U; + } } // Service with invalid hooks (returning wrong types) @@ -105,13 +106,13 @@ class TestServiceWithInvalidHooks< C extends QueryResultRow, U extends QueryResultRow, > extends TestService { - async preCreate(): Promise { - return "invalid-type" as unknown as C; - } - async postCreate(): Promise { return "invalid-type" as unknown as T; } + + async preCreate(): Promise { + return "invalid-type" as unknown as C; + } } describe("Service Hooks", async () => { @@ -128,7 +129,7 @@ describe("Service Hooks", async () => { describe("Pre-hooks", () => { it("calls preCreate hook and modifies data before create", async () => { const service = new TestServiceWithHooks(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const result = await service.create(data); @@ -148,7 +149,7 @@ describe("Service Hooks", async () => { it("handles missing pre-hooks gracefully", async () => { const service = new TestService(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const result = await service.create(data); @@ -157,7 +158,7 @@ describe("Service Hooks", async () => { it("returns original data when pre-hook returns wrong type", async () => { const service = new TestServiceWithInvalidHooks(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const result = await service.create(data); @@ -167,7 +168,7 @@ describe("Service Hooks", async () => { it("verifies pre-hooks are called and modify data", async () => { const service = new TestServiceWithHooks(config, database); - const data = { name: "TrackingTest", latitude: 25, countryCode: "DE" }; + const data = { countryCode: "DE", latitude: 25, name: "TrackingTest" }; const result = await service.create(data); @@ -201,7 +202,7 @@ describe("Service Hooks", async () => { it("calls postCreate hook and modifies result", async () => { const service = new TestServiceWithHooks(config, database); - const data = { name: "PostTest", latitude: 15, countryCode: "IT" }; + const data = { countryCode: "IT", latitude: 15, name: "PostTest" }; const result = await service.create(data); @@ -261,7 +262,7 @@ describe("Service Hooks", async () => { const service = new TestServiceWithHooks(config, database); // First create a record to delete - const createData = { name: "ToDelete", latitude: 50, countryCode: "CA" }; + const createData = { countryCode: "CA", latitude: 50, name: "ToDelete" }; const created = await service.create(createData); const result = await service.delete(created!.id as number); @@ -280,7 +281,7 @@ describe("Service Hooks", async () => { it("returns original result when post-hook returns wrong type", async () => { const service = new TestServiceWithInvalidHooks(config, database); - const data = { name: "Test", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "Test" }; const result = await service.create(data); @@ -291,9 +292,9 @@ describe("Service Hooks", async () => { it("verifies post-hooks are called and modify results", async () => { const service = new TestServiceWithHooks(config, database); const data = { - name: "PostTrackingTest", - latitude: 25, countryCode: "DE", + latitude: 25, + name: "PostTrackingTest", }; const result = await service.create(data); @@ -307,7 +308,7 @@ describe("Service Hooks", async () => { describe("Hook execution order", () => { it("executes pre-hook before and post-hook after processing", async () => { const service = new TestServiceWithHooks(config, database); - const data = { name: "OrderTest", latitude: 35, countryCode: "ES" }; + const data = { countryCode: "ES", latitude: 35, name: "OrderTest" }; const result = await service.create(data); @@ -323,9 +324,9 @@ describe("Service Hooks", async () => { // First create a record to update const createData = { - name: "ToBeUpdated", - latitude: 10, countryCode: "US", + latitude: 10, + name: "ToBeUpdated", }; const created = await service.create(createData); @@ -387,7 +388,7 @@ describe("Service Hooks", async () => { it("calls pre-hooks with data for write operations", async () => { const service = new TestServiceWithHooks(config, database); - const createData = { name: "WithData", latitude: 40, countryCode: "JP" }; + const createData = { countryCode: "JP", latitude: 40, name: "WithData" }; const created = await service.create(createData); const updateData = { name: "UpdatedWithData" }; @@ -411,7 +412,7 @@ describe("Service Hooks", async () => { } const service = new ErrorService(config, database); - const data = { name: "ErrorTest", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "ErrorTest" }; // Should throw the error from the pre-hook await expect(service.create(data)).rejects.toThrow("Pre-hook error"); @@ -429,7 +430,7 @@ describe("Service Hooks", async () => { } const service = new ErrorService(config, database); - const data = { name: "ErrorTest", latitude: 20, countryCode: "FR" }; + const data = { countryCode: "FR", latitude: 20, name: "ErrorTest" }; // Should throw the error from the post-hook await expect(service.create(data)).rejects.toThrow("Post-hook error"); diff --git a/packages/slonik/src/__test__/sql.test.ts b/packages/slonik/src/__test__/sql.test.ts index 3c4d6e167..9bedd21ee 100644 --- a/packages/slonik/src/__test__/sql.test.ts +++ b/packages/slonik/src/__test__/sql.test.ts @@ -1,6 +1,16 @@ -import { describe, it, expect } from "vitest"; +import { sql } from "slonik"; +import { describe, expect, it } from "vitest"; -import { isValueExpression } from "../sql"; +import { + createFilterFragment, + createLimitFragment, + createSortFragment, + createTableFragment, + createTableIdentifier, + createWhereFragment, + createWhereIdFragment, + isValueExpression, +} from "../sql"; describe("isValueExpression", () => { it("should return true for valid ValueExpression types", () => { @@ -38,3 +48,143 @@ describe("isValueExpression", () => { ).toBe(false); }); }); + +describe("createWhereIdFragment", () => { + it("returns a fragment containing WHERE id =", () => { + const fragment = createWhereIdFragment(1); + expect(fragment.sql).toMatch(/WHERE id = \$slonik_\d+/); + }); + + it("works with a numeric id", () => { + const fragment = createWhereIdFragment(42); + expect(fragment.values).toContain(42); + }); + + it("works with a string id", () => { + const fragment = createWhereIdFragment("abc-123"); + expect(fragment.values).toContain("abc-123"); + }); +}); + +describe("createFilterFragment", () => { + const tableIdentifier = createTableIdentifier("users"); + + it("returns an empty fragment when no filters provided", () => { + const fragment = createFilterFragment(undefined, tableIdentifier); + expect(fragment.sql.trim()).toBe(""); + }); + + it("returns a WHERE clause when filters are provided", () => { + const fragment = createFilterFragment( + { key: "name", operator: "eq", value: "alice" }, + tableIdentifier, + ); + expect(fragment.sql).toMatch(/WHERE/); + }); +}); + +describe("createLimitFragment", () => { + it("returns LIMIT n without offset", () => { + const fragment = createLimitFragment(10); + expect(fragment.sql).toContain("LIMIT"); + expect(fragment.values).toContain(10); + expect(fragment.sql).not.toContain("OFFSET"); + }); + + it("returns LIMIT n OFFSET m when offset is provided", () => { + const fragment = createLimitFragment(10, 20); + expect(fragment.sql).toContain("LIMIT"); + expect(fragment.sql).toContain("OFFSET"); + expect(fragment.values).toContain(10); + expect(fragment.values).toContain(20); + }); + + it("does not include OFFSET when offset is 0 (falsy)", () => { + const fragment = createLimitFragment(10, 0); + expect(fragment.sql).not.toContain("OFFSET"); + }); +}); + +describe("createTableFragment", () => { + it("returns unqualified identifier when no schema given", () => { + const fragment = createTableFragment("users"); + expect(fragment.sql).toMatch(/"users"/); + }); + + it("returns schema-qualified identifier when schema given", () => { + const fragment = createTableFragment("users", "tenant1"); + expect(fragment.sql).toMatch(/"tenant1"\."users"/); + }); +}); + +describe("createWhereFragment", () => { + const tableIdentifier = createTableIdentifier("users"); + + it("returns empty fragment when no filters and no extra fragments", () => { + const fragment = createWhereFragment(tableIdentifier); + expect(fragment.sql.trim()).toBe(""); + }); + + it("returns WHERE clause when filters are provided", () => { + const fragment = createWhereFragment(tableIdentifier, { + key: "name", + operator: "eq", + value: "alice", + }); + expect(fragment.sql).toMatch(/WHERE/i); + }); + + it("returns WHERE clause when only extra fragments are provided", () => { + const extra = sql.fragment`status = 'active'`; + const fragment = createWhereFragment(tableIdentifier, undefined, [extra]); + expect(fragment.sql).toMatch(/WHERE/i); + }); + + it("combines filters and extra fragments with AND", () => { + const extra = sql.fragment`status = 'active'`; + const fragment = createWhereFragment( + tableIdentifier, + { key: "name", operator: "eq", value: "alice" }, + [extra], + ); + expect(fragment.sql).toMatch(/AND/i); + }); + + it("strips leading WHERE keyword from extra fragments", () => { + const extraWithWhere = sql.fragment`WHERE status = 'active'`; + const fragment = createWhereFragment(tableIdentifier, undefined, [ + extraWithWhere, + ]); + // Should produce a single WHERE clause, not WHERE WHERE + const whereCount = (fragment.sql.match(/WHERE/gi) || []).length; + expect(whereCount).toBe(1); + }); +}); + +describe("createSortFragment", () => { + const tableIdentifier = createTableIdentifier("users"); + + it("returns an empty fragment when sort array is empty", () => { + const fragment = createSortFragment(tableIdentifier, []); + expect(fragment.sql.trim()).toBe(""); + }); + + it("returns an ORDER BY clause for a single sort entry", () => { + const fragment = createSortFragment(tableIdentifier, [ + { direction: "ASC", key: "name" }, + ]); + expect(fragment.sql).toMatch(/ORDER BY/); + }); + + it("returns DESC when direction is DESC", () => { + const fragment = createSortFragment(tableIdentifier, [ + { direction: "DESC", key: "id" }, + ]); + expect(fragment.sql).toContain("DESC"); + }); + + it("returns empty fragment when sort is undefined", () => { + const fragment = createSortFragment(tableIdentifier); + expect(fragment.sql.trim()).toBe(""); + }); +}); diff --git a/packages/slonik/src/__test__/sqlFactory.test.ts b/packages/slonik/src/__test__/sqlFactory.test.ts new file mode 100644 index 000000000..1fab38604 --- /dev/null +++ b/packages/slonik/src/__test__/sqlFactory.test.ts @@ -0,0 +1,133 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { newDb } from "pg-mem"; +import { describe, expect, it } from "vitest"; + +import type { Database } from "../types"; + +import DefaultSqlFactory from "../sqlFactory"; +import createConfig from "./helpers/createConfig"; +import createDatabase from "./helpers/createDatabase"; + +class HardDeleteFactory extends DefaultSqlFactory { + static readonly TABLE = "test"; +} + +class SoftDeleteFactory extends DefaultSqlFactory { + static readonly TABLE = "test"; + protected _softDeleteEnabled = true; +} + +const makeFactory = async ( + FactoryClass: typeof DefaultSqlFactory, + config: ApiConfig, + database: Database, +) => new FactoryClass(config, database); + +describe("DefaultSqlFactory — soft delete", async () => { + const db = newDb(); + db.public.none( + `create table test(id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT, deleted_at TIMESTAMP);`, + ); + const config = createConfig(); + const database = await createDatabase({ db }); + + it("getDeleteSql generates UPDATE SET deleted_at when soft-delete is enabled", async () => { + const factory = await makeFactory(SoftDeleteFactory, config, database); + const query = factory.getDeleteSql(1); + expect(query.sql).toMatch(/UPDATE/i); + expect(query.sql).toMatch(/deleted_at/i); + expect(query.sql).not.toMatch(/DELETE FROM/i); + }); + + it("getDeleteSql with force=true generates DELETE even when soft-delete is enabled", async () => { + const factory = await makeFactory(SoftDeleteFactory, config, database); + const query = factory.getDeleteSql(1, true); + expect(query.sql).toMatch(/DELETE FROM/i); + }); + + it("getDeleteSql generates DELETE when soft-delete is disabled", async () => { + const factory = await makeFactory(HardDeleteFactory, config, database); + const query = factory.getDeleteSql(1); + expect(query.sql).toMatch(/DELETE FROM/i); + }); + + it("getSoftDeleteFilterFragment returns empty fragment when soft-delete disabled", async () => { + const factory = await makeFactory(HardDeleteFactory, config, database); + const fragment = factory["getSoftDeleteFilterFragment"](true); + expect(fragment.sql.trim()).toBe(""); + }); + + it("getSoftDeleteFilterFragment returns WHERE clause when addWhere is true", async () => { + const factory = await makeFactory(SoftDeleteFactory, config, database); + const fragment = factory["getSoftDeleteFilterFragment"](true); + expect(fragment.sql).toMatch(/WHERE/i); + expect(fragment.sql).toMatch(/deleted_at IS NULL/); + }); + + it("getSoftDeleteFilterFragment returns AND clause when addWhere is false", async () => { + const factory = await makeFactory(SoftDeleteFactory, config, database); + const fragment = factory["getSoftDeleteFilterFragment"](false); + expect(fragment.sql).toMatch(/AND/i); + expect(fragment.sql).toMatch(/deleted_at IS NULL/); + }); +}); + +describe("DefaultSqlFactory — static defaults", () => { + it("LIMIT_DEFAULT is 20", () => { + expect(DefaultSqlFactory.LIMIT_DEFAULT).toBe(20); + }); + + it("LIMIT_MAX is 50", () => { + expect(DefaultSqlFactory.LIMIT_MAX).toBe(50); + }); + + it("SORT_DIRECTION is ASC", () => { + expect(DefaultSqlFactory.SORT_DIRECTION).toBe("ASC"); + }); + + it("SORT_KEY is id", () => { + expect(DefaultSqlFactory.SORT_KEY).toBe("id"); + }); +}); + +describe("DefaultSqlFactory — default schema", async () => { + const db = newDb(); + db.public.none( + `create table test(id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT);`, + ); + const config = createConfig(); + const database = await createDatabase({ db }); + + class SimpleFactory extends DefaultSqlFactory { + static readonly TABLE = "test"; + } + + it("schema is public when no schema passed to constructor", async () => { + const factory = await makeFactory(SimpleFactory, config, database); + expect(factory.schema).toBe("public"); + }); + + it("schema uses provided value when passed", async () => { + const factory = new SimpleFactory(config, database, "tenant1"); + expect(factory.schema).toBe("tenant1"); + }); +}); + +describe("DefaultSqlFactory — deprecated getTableFragment", async () => { + const db = newDb(); + db.public.none( + `create table test(id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT);`, + ); + const config = createConfig(); + const database = await createDatabase({ db }); + + class SimpleFactory extends DefaultSqlFactory { + static readonly TABLE = "test"; + } + + it("getTableFragment (deprecated) returns the same result as tableFragment getter", async () => { + const factory = await makeFactory(SimpleFactory, config, database); + expect(factory.getTableFragment().sql).toBe(factory.tableFragment.sql); + }); +}); diff --git a/packages/slonik/src/createDatabase.ts b/packages/slonik/src/createDatabase.ts index 42d00d31c..2ca3c1e0e 100644 --- a/packages/slonik/src/createDatabase.ts +++ b/packages/slonik/src/createDatabase.ts @@ -1,9 +1,10 @@ -import { createPool } from "slonik"; +import type { ClientConfiguration, DatabasePool } from "slonik"; -import createClientConfiguration from "./factories/createClientConfiguration"; +import { createPool } from "slonik"; import type { Database } from "./types"; -import type { ClientConfiguration, DatabasePool } from "slonik"; + +import createClientConfiguration from "./factories/createClientConfiguration"; const createDatabase = async ( connectionString: string, diff --git a/packages/slonik/src/factories/__test__/createClientConfiguration.test.ts b/packages/slonik/src/factories/__test__/createClientConfiguration.test.ts index b53763ed4..27dcbc5df 100644 --- a/packages/slonik/src/factories/__test__/createClientConfiguration.test.ts +++ b/packages/slonik/src/factories/__test__/createClientConfiguration.test.ts @@ -1,3 +1,5 @@ +import type { Query, QueryContext } from "slonik"; + /* istanbul ignore file */ import { createTypeParserPreset } from "slonik"; import { describe, expect, it } from "vitest"; @@ -7,8 +9,6 @@ import resultParser from "../../interceptors/resultParser"; import { createBigintTypeParser } from "../../typeParsers/createBigintTypeParser"; import createClientConfiguration from "../createClientConfiguration"; -import type { Query, QueryContext } from "slonik"; - describe("createClientConfiguration helper", () => { const defaultConfiguration = { captureStackTrace: false, @@ -43,4 +43,33 @@ describe("createClientConfiguration helper", () => { expect(configuration.interceptors).toContain(fieldNameCaseConverter); }); + + it("includes query logging interceptor when queryLoggingEnabled is true", () => { + const configuration = createClientConfiguration(undefined, true); + // The logging interceptor is the extra one beyond fieldNameCaseConverter + resultParser + expect(configuration.interceptors.length).toBeGreaterThan(2); + }); + + it("does not include query logging interceptor when queryLoggingEnabled is false", () => { + const configuration = createClientConfiguration(undefined, false); + expect(configuration.interceptors).toHaveLength(2); + }); + + it("does not include query logging interceptor when queryLoggingEnabled is undefined", () => { + const configuration = createClientConfiguration(); + expect(configuration.interceptors).toHaveLength(2); + }); + + it("appends user interceptors after built-in interceptors", () => { + const userInterceptor = { + transformRow: (_context: unknown, _query: unknown, row: unknown) => row, + }; + const configuration = createClientConfiguration({ + interceptors: [userInterceptor as never], + }); + // built-ins come first, user interceptor is last + expect(configuration.interceptors[0]).toBe(fieldNameCaseConverter); + expect(configuration.interceptors[1]).toBe(resultParser); + expect(configuration.interceptors.at(-1)).toBe(userInterceptor); + }); }); diff --git a/packages/slonik/src/factories/createClientConfiguration.ts b/packages/slonik/src/factories/createClientConfiguration.ts index 7be82eb96..3ef56e146 100644 --- a/packages/slonik/src/factories/createClientConfiguration.ts +++ b/packages/slonik/src/factories/createClientConfiguration.ts @@ -1,3 +1,5 @@ +import type { ClientConfigurationInput } from "slonik"; + import { createTypeParserPreset } from "slonik"; import { createQueryLoggingInterceptor } from "slonik-interceptor-query-logging"; @@ -5,8 +7,6 @@ import fieldNameCaseConverter from "../interceptors/fieldNameCaseConverter"; import resultParser from "../interceptors/resultParser"; import { createBigintTypeParser } from "../typeParsers/createBigintTypeParser"; -import type { ClientConfigurationInput } from "slonik"; - const createClientConfiguration = ( config?: ClientConfigurationInput, queryLoggingEnabled?: boolean, diff --git a/packages/slonik/src/filters.ts b/packages/slonik/src/filters.ts index 6450ddada..1f2689e77 100644 --- a/packages/slonik/src/filters.ts +++ b/packages/slonik/src/filters.ts @@ -1,8 +1,9 @@ +import type { FragmentSqlToken, IdentifierSqlToken } from "slonik"; + import humps from "humps"; import { sql } from "slonik"; import type { BaseFilterInput, FilterInput } from "./types"; -import type { IdentifierSqlToken, FragmentSqlToken } from "slonik"; const applyFilter = ( tableIdentifier: IdentifierSqlToken, @@ -43,9 +44,24 @@ const applyFilter = ( } switch (operator) { + case "bt": { + const [start, end] = value.split(","); + + if (!start || !end) { + throw new Error("BETWEEN operator requires exactly two values"); + } + + clauseOperator = not ? sql.fragment`NOT BETWEEN` : sql.fragment`BETWEEN`; + + value = insensitive + ? sql.fragment`unaccent(lower(${start})) AND unaccent(lower(${end}))` + : sql.fragment`${start} AND ${end}`; + + break; + } case "ct": - case "sw": - case "ew": { + case "ew": + case "sw": { const valueString = { ct: `%${value}%`, // contains ew: `%${value}`, // ends with @@ -60,16 +76,6 @@ const applyFilter = ( break; } - case "eq": - default: { - clauseOperator = not ? sql.fragment`!=` : sql.fragment`=`; - - if (insensitive) { - value = sql.fragment`unaccent(lower(${value}))`; - } - - break; - } case "gt": { clauseOperator = not ? sql.fragment`<` : sql.fragment`>`; @@ -88,24 +94,6 @@ const applyFilter = ( break; } - case "lte": { - clauseOperator = not ? sql.fragment`>` : sql.fragment`<=`; - - if (insensitive) { - value = sql.fragment`unaccent(lower(${value}))`; - } - - break; - } - case "lt": { - clauseOperator = not ? sql.fragment`>` : sql.fragment`<`; - - if (insensitive) { - value = sql.fragment`unaccent(lower(${value}))`; - } - - break; - } case "in": { const values = value.split(",").filter(Boolean); @@ -124,18 +112,30 @@ const applyFilter = ( break; } - case "bt": { - const [start, end] = value.split(","); + case "lt": { + clauseOperator = not ? sql.fragment`>` : sql.fragment`<`; - if (!start || !end) { - throw new Error("BETWEEN operator requires exactly two values"); + if (insensitive) { + value = sql.fragment`unaccent(lower(${value}))`; } - clauseOperator = not ? sql.fragment`NOT BETWEEN` : sql.fragment`BETWEEN`; + break; + } + case "lte": { + clauseOperator = not ? sql.fragment`>` : sql.fragment`<=`; - value = insensitive - ? sql.fragment`unaccent(lower(${start})) AND unaccent(lower(${end}))` - : sql.fragment`${start} AND ${end}`; + if (insensitive) { + value = sql.fragment`unaccent(lower(${value}))`; + } + + break; + } + default: { + clauseOperator = not ? sql.fragment`!=` : sql.fragment`=`; + + if (insensitive) { + value = sql.fragment`unaccent(lower(${value}))`; + } break; } diff --git a/packages/slonik/src/index.ts b/packages/slonik/src/index.ts index 95402abcb..686e4ddfe 100644 --- a/packages/slonik/src/index.ts +++ b/packages/slonik/src/index.ts @@ -1,7 +1,8 @@ +import type { ConnectionRoutine, DatabasePool, QueryFunction } from "slonik"; + import { sql } from "slonik"; import type { SlonikConfig } from "./types"; -import type { ConnectionRoutine, DatabasePool, QueryFunction } from "slonik"; declare module "fastify" { interface FastifyInstance { @@ -30,16 +31,16 @@ declare module "@prefabs.tech/fastify-config" { } } -export { default } from "./plugin"; +export { default as createDatabase } from "./createDatabase"; export * from "./filters"; -export * from "./sql"; +export { default as formatDate } from "./formatDate"; -export { createBigintTypeParser } from "./typeParsers/createBigintTypeParser"; -export { default as createDatabase } from "./createDatabase"; +export { default as migrationPlugin } from "./migrationPlugin"; +export { default } from "./plugin"; export { default as BaseService } from "./service"; +export * from "./sql"; export { default as DefaultSqlFactory } from "./sqlFactory"; -export { default as formatDate } from "./formatDate"; -export { default as migrationPlugin } from "./migrationPlugin"; +export { createBigintTypeParser } from "./typeParsers/createBigintTypeParser"; export type * from "./types"; diff --git a/packages/slonik/src/interceptors/__test__/resultParser.test.ts b/packages/slonik/src/interceptors/__test__/resultParser.test.ts new file mode 100644 index 000000000..22af65b94 --- /dev/null +++ b/packages/slonik/src/interceptors/__test__/resultParser.test.ts @@ -0,0 +1,60 @@ +import { SchemaValidationError } from "slonik"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import resultParser from "../resultParser"; +import createQueryContext from "./helpers/createQueryContext"; + +const fakeQuery = { sql: "SELECT 1", values: [] }; +const fakeFields = [{ dataTypeId: 1, name: "id" }]; + +const contextWithParser = (schema: z.ZodTypeAny) => ({ + ...createQueryContext(), + resultParser: schema, +}); + +describe("resultParser interceptor", () => { + const { transformRow } = resultParser; + + if (!transformRow) throw new Error("transformRow must be defined"); + + it("returns row unchanged when queryContext has no resultParser", () => { + const row = { id: 1, name: "Alice" }; + const result = transformRow( + createQueryContext(), + fakeQuery, + row, + fakeFields, + ); + expect(result).toBe(row); + }); + + it("returns parsed data when zod schema passes validation", () => { + const schema = z.object({ id: z.number(), name: z.string() }); + const row = { id: 1, name: "Alice" }; + const result = transformRow( + contextWithParser(schema), + fakeQuery, + row, + fakeFields, + ); + expect(result).toEqual({ id: 1, name: "Alice" }); + }); + + it("throws SchemaValidationError when zod schema fails validation", () => { + const schema = z.object({ id: z.number() }); + const row = { id: "not-a-number" }; + + expect(() => + transformRow(contextWithParser(schema), fakeQuery, row, fakeFields), + ).toThrow(SchemaValidationError); + }); + + it("does not mutate the original row when validation passes", () => { + const schema = z.object({ id: z.number() }); + const row = { id: 1 }; + const original = { ...row }; + transformRow(contextWithParser(schema), fakeQuery, row, fakeFields); + expect(row).toEqual(original); + }); +}); diff --git a/packages/slonik/src/interceptors/fieldNameCaseConverter.ts b/packages/slonik/src/interceptors/fieldNameCaseConverter.ts index 944a5b266..2351cbce3 100644 --- a/packages/slonik/src/interceptors/fieldNameCaseConverter.ts +++ b/packages/slonik/src/interceptors/fieldNameCaseConverter.ts @@ -1,5 +1,3 @@ -import humps from "humps"; - import type { Field, Interceptor, @@ -8,6 +6,8 @@ import type { QueryResultRow, } from "slonik"; +import humps from "humps"; + const fieldNameCaseConverter: Interceptor = { transformRow: ( /* eslint-disable @typescript-eslint/no-unused-vars */ diff --git a/packages/slonik/src/interceptors/resultParser.ts b/packages/slonik/src/interceptors/resultParser.ts index adbde7547..27d01298c 100644 --- a/packages/slonik/src/interceptors/resultParser.ts +++ b/packages/slonik/src/interceptors/resultParser.ts @@ -1,13 +1,13 @@ -import { SchemaValidationError } from "slonik"; - import type { Field, Interceptor, - QueryResultRow, Query, QueryContext, + QueryResultRow, } from "slonik"; +import { SchemaValidationError } from "slonik"; + const createResultParser: Interceptor = { // If you are not going to transform results using Zod, then you should use `afterQueryExecution` instead. // Future versions of Zod will provide a more efficient parser when parsing without transformations. diff --git a/packages/slonik/src/migrate.ts b/packages/slonik/src/migrate.ts index b533701dc..54dd1dabe 100644 --- a/packages/slonik/src/migrate.ts +++ b/packages/slonik/src/migrate.ts @@ -1,8 +1,9 @@ +import type { ClientConfig } from "pg"; + import { migrate as runMigrations } from "@prefabs.tech/postgres-migrations"; import * as pg from "pg"; import type { SlonikOptions } from "./types"; -import type { ClientConfig } from "pg"; const migrate = async (slonikOptions: SlonikOptions) => { const defaultMigrationsPath = "migrations"; diff --git a/packages/slonik/src/migrationPlugin.ts b/packages/slonik/src/migrationPlugin.ts index d16c211f2..321d84a02 100644 --- a/packages/slonik/src/migrationPlugin.ts +++ b/packages/slonik/src/migrationPlugin.ts @@ -1,9 +1,10 @@ -import FastifyPlugin from "fastify-plugin"; +import type { FastifyInstance } from "fastify"; -import migrate from "./migrate"; +import FastifyPlugin from "fastify-plugin"; import type { SlonikOptions } from "./types"; -import type { FastifyInstance } from "fastify"; + +import migrate from "./migrate"; const plugin = async (fastify: FastifyInstance, options: SlonikOptions) => { fastify.log.info("Running database migrations"); diff --git a/packages/slonik/src/migrations/__test__/queryToCreateExtensions.test.ts b/packages/slonik/src/migrations/__test__/queryToCreateExtensions.test.ts new file mode 100644 index 000000000..84a12cc48 --- /dev/null +++ b/packages/slonik/src/migrations/__test__/queryToCreateExtensions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import queryToCreateExtension from "../queryToCreateExtensions"; + +describe("queryToCreateExtension", () => { + it("returns an object with a sql string", () => { + const result = queryToCreateExtension("citext"); + expect(typeof result.sql).toBe("string"); + }); + + it("generated SQL contains CREATE EXTENSION IF NOT EXISTS", () => { + const result = queryToCreateExtension("citext"); + expect(result.sql).toMatch(/CREATE EXTENSION IF NOT EXISTS/i); + }); + + it("generated SQL references the provided extension name as an identifier", () => { + const result = queryToCreateExtension("unaccent"); + expect(result.sql).toContain('"unaccent"'); + }); + + it("works for any extension name", () => { + const result = queryToCreateExtension("pgcrypto"); + expect(result.sql).toContain('"pgcrypto"'); + }); +}); diff --git a/packages/slonik/src/migrations/__test__/runMigrations.test.ts b/packages/slonik/src/migrations/__test__/runMigrations.test.ts new file mode 100644 index 000000000..056d2ae35 --- /dev/null +++ b/packages/slonik/src/migrations/__test__/runMigrations.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Database, SlonikOptions } from "../../types"; + +import { EXTENSIONS } from "../../constants"; +import runMigrations from "../runMigrations"; + +const makeDatabase = () => { + const queryMock = vi.fn().mockResolvedValue({ rows: [] }); + const connectMock = vi.fn().mockImplementation(async (routine) => { + const connection = { query: queryMock }; + return routine(connection); + }); + + return { + connectMock, + database: { connect: connectMock } as unknown as Database, + queryMock, + }; +}; + +const baseOptions: SlonikOptions = { + db: { + databaseName: "test", + host: "localhost", + password: "pass", + username: "user", + }, +}; + +describe("runMigrations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls database.connect once", async () => { + const { connectMock, database } = makeDatabase(); + await runMigrations(database, baseOptions); + expect(connectMock).toHaveBeenCalledTimes(1); + }); + + it("creates an extension for each default extension", async () => { + const { database, queryMock } = makeDatabase(); + await runMigrations(database, baseOptions); + expect(queryMock).toHaveBeenCalledTimes(EXTENSIONS.length); + }); + + it("includes citext and unaccent by default", async () => { + const { database, queryMock } = makeDatabase(); + await runMigrations(database, baseOptions); + + const sqls = queryMock.mock.calls.map((call) => call[0].sql as string); + expect(sqls.some((s) => s.includes('"citext"'))).toBe(true); + expect(sqls.some((s) => s.includes('"unaccent"'))).toBe(true); + }); + + it("merges custom extensions with defaults", async () => { + const { database, queryMock } = makeDatabase(); + await runMigrations(database, { + ...baseOptions, + extensions: ["pgcrypto"], + }); + + const sqls = queryMock.mock.calls.map((call) => call[0].sql as string); + expect(sqls.some((s) => s.includes('"pgcrypto"'))).toBe(true); + expect(sqls.some((s) => s.includes('"citext"'))).toBe(true); + }); + + it("deduplicates extensions when custom overlaps with defaults", async () => { + const { database, queryMock } = makeDatabase(); + await runMigrations(database, { + ...baseOptions, + extensions: ["citext", "pgcrypto"], // "citext" is already in EXTENSIONS + }); + + const sqls = queryMock.mock.calls.map((call) => call[0].sql as string); + const citextCalls = sqls.filter((s) => s.includes('"citext"')); + expect(citextCalls).toHaveLength(1); + }); + + it("calls connection.query for each unique extension", async () => { + const { database, queryMock } = makeDatabase(); + const extraExtensions = ["pgcrypto", "uuid-ossp"]; + await runMigrations(database, { + ...baseOptions, + extensions: extraExtensions, + }); + + const expectedCount = new Set([...EXTENSIONS, ...extraExtensions]).size; + expect(queryMock).toHaveBeenCalledTimes(expectedCount); + }); +}); diff --git a/packages/slonik/src/migrations/queryToCreateExtensions.ts b/packages/slonik/src/migrations/queryToCreateExtensions.ts index 14c54f4fb..1a525ced1 100644 --- a/packages/slonik/src/migrations/queryToCreateExtensions.ts +++ b/packages/slonik/src/migrations/queryToCreateExtensions.ts @@ -1,8 +1,8 @@ -import { sql } from "slonik"; - import type { QuerySqlToken } from "slonik"; import type { ZodTypeAny } from "zod"; +import { sql } from "slonik"; + const queryToCreateExtension = ( extension: string, ): QuerySqlToken => { diff --git a/packages/slonik/src/migrations/runMigrations.ts b/packages/slonik/src/migrations/runMigrations.ts index 675b2aec2..21fd347f5 100644 --- a/packages/slonik/src/migrations/runMigrations.ts +++ b/packages/slonik/src/migrations/runMigrations.ts @@ -1,8 +1,8 @@ -import queryToCreateExtension from "./queryToCreateExtensions"; -import { EXTENSIONS } from "../constants"; - import type { Database, SlonikOptions } from "../types"; +import { EXTENSIONS } from "../constants"; +import queryToCreateExtension from "./queryToCreateExtensions"; + const runMigrations = async (database: Database, options: SlonikOptions) => { const extensions = [ ...new Set([...EXTENSIONS, ...(options.extensions || [])]), diff --git a/packages/slonik/src/plugin.ts b/packages/slonik/src/plugin.ts index 1f066bb68..6cf009a8c 100644 --- a/packages/slonik/src/plugin.ts +++ b/packages/slonik/src/plugin.ts @@ -1,13 +1,14 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { stringifyDsn } from "slonik"; +import type { SlonikOptions } from "./types"; + import createClientConfiguration from "./factories/createClientConfiguration"; import runMigrations from "./migrations/runMigrations"; import { fastifySlonik } from "./slonik"; -import type { SlonikOptions } from "./types"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance, options: SlonikOptions) => { fastify.log.info("Registering fastify-slonik plugin"); @@ -26,11 +27,11 @@ const plugin = async (fastify: FastifyInstance, options: SlonikOptions) => { } await fastify.register(fastifySlonik, { - connectionString: stringifyDsn(options.db), clientConfiguration: createClientConfiguration( options.clientConfiguration, options.queryLogging?.enabled, ), + connectionString: stringifyDsn(options.db), }); await runMigrations(fastify.slonik, options); diff --git a/packages/slonik/src/service.ts b/packages/slonik/src/service.ts index fd1be672a..555bbb41f 100644 --- a/packages/slonik/src/service.ts +++ b/packages/slonik/src/service.ts @@ -1,4 +1,4 @@ -import DefaultSqlFactory from "./sqlFactory"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database, @@ -8,19 +8,49 @@ import type { SqlFactory, } from "./types"; import type { PaginatedList } from "./types/service"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import DefaultSqlFactory from "./sqlFactory"; abstract class BaseService< T, C extends Record, U extends Record, > implements Service { + get config(): ApiConfig { + return this._config; + } + get database(): Database { + return this._database; + } + get factory(): SqlFactory { + if (!this._factory) { + const sqlFactoryClass = this.sqlFactoryClass; + + this._factory = new sqlFactoryClass( + this.config, + this.database, + this.schema, + ); + } + + return this._factory; + } + get schema(): string { + return this._schema || "public"; + } + + get sqlFactoryClass() { + return DefaultSqlFactory; + } + + get table(): string { + return this.factory.table; + } /* eslint-enabled */ protected _config: ApiConfig; protected _database: Database; protected _factory: SqlFactory | undefined; protected _schema = "public"; - constructor(config: ApiConfig, database: Database, schema?: string) { this._config = config; this._database = database; @@ -29,31 +59,6 @@ abstract class BaseService< this._schema = schema; } } - - // Type-safe optional hook methods for pre-processing - protected preAll?(): Promise; - protected preCount?(): Promise; - protected preCreate?(data: C): Promise; - protected preDelete?(id: number | string): Promise; - protected preFind?(): Promise; - protected preFindById?(id: number | string): Promise; - protected preFindOne?(): Promise; - protected preList?(): Promise; - protected preUpdate?(data: U): Promise; - - // Type-safe optional hook methods for post-processing - protected postAll?( - result: Partial, - ): Promise>; - protected postCount?(result: number): Promise; - // protected postCreate?(result: T): Promise; - protected postDelete?(result: T): Promise; - protected postFind?(result: readonly T[]): Promise; - protected postFindById?(result: T): Promise; - protected postFindOne?(result: T): Promise; - protected postList?(result: PaginatedList): Promise>; - protected postUpdate?(result: T): Promise; - /** * Only for entities that support it. Returns the full list of entities, * with no filtering, no custom sorting order, no pagination, @@ -74,7 +79,6 @@ abstract class BaseService< return await this.postProcess>("all", result); } - async count(filters?: FilterInput): Promise { await this.preProcess("count"); @@ -88,7 +92,6 @@ abstract class BaseService< return await this.postProcess("count", count); } - async create(data: C): Promise { const processedData = await this.preProcess("create", data); @@ -103,7 +106,7 @@ abstract class BaseService< return result ? await this.postProcess("create", result) : undefined; } - async delete(id: number | string, force?: boolean): Promise { + async delete(id: number | string, force?: boolean): Promise { await this.preProcess("delete", id); const query = this.factory.getDeleteSql(id, force); @@ -114,7 +117,6 @@ abstract class BaseService< return result ? await this.postProcess("delete", result) : result; } - async find(filters?: FilterInput, sort?: SortInput[]): Promise { await this.preProcess("find"); @@ -126,33 +128,30 @@ abstract class BaseService< return await this.postProcess("find", result); } - - async findById(id: number | string): Promise { + async findById(id: number | string): Promise { await this.preProcess("findById"); const query = this.factory.getFindByIdSql(id); const result = (await this.database.connect((connection) => { return connection.maybeOne(query); - })) as T | null; + })) as null | T; // eslint-disable-next-line unicorn/no-null return result ? await this.postProcess("findById", result) : null; } - - async findOne(filters?: FilterInput, sort?: SortInput[]): Promise { + async findOne(filters?: FilterInput, sort?: SortInput[]): Promise { await this.preProcess("findOne"); const query = this.factory.getFindOneSql(filters, sort); const result = (await this.database.connect((connection) => { return connection.maybeOne(query); - })) as T | null; + })) as null | T; // eslint-disable-next-line unicorn/no-null return result ? await this.postProcess("findOne", result) : null; } - async list( limit?: number, offset?: number, @@ -172,14 +171,13 @@ abstract class BaseService< ]); const result = { - totalCount, - filteredCount, data: data as readonly T[], + filteredCount, + totalCount, }; return await this.postProcess>("list", result); } - async update(id: number | string, data: U): Promise { const processedData = await this.preProcess("update", data); @@ -193,47 +191,11 @@ abstract class BaseService< return await this.postProcess("update", result); } - - get config(): ApiConfig { - return this._config; - } - - get database(): Database { - return this._database; - } - - get factory(): SqlFactory { - if (!this._factory) { - const sqlFactoryClass = this.sqlFactoryClass; - - this._factory = new sqlFactoryClass( - this.config, - this.database, - this.schema, - ); - } - - return this._factory; - } - - get schema(): string { - return this._schema || "public"; - } - - get sqlFactoryClass() { - return DefaultSqlFactory; - } - - get table(): string { - return this.factory.table; - } - protected getHook(prefix: string, action: string): unknown { const hookName = `${prefix}${action.charAt(0).toUpperCase()}${action.slice(1)}`; return (this as Record)[hookName]; } - protected isCompatibleType(processed: T, original: T): boolean { // If original is undefined, processed can be anything if (original === undefined) { @@ -265,33 +227,28 @@ abstract class BaseService< return true; } - protected async preProcess( - action: string, - data?: D, - ): Promise { - const preHook = this.getHook("pre", action); - - if (typeof preHook === "function") { - const processedData = await ( - preHook as (data?: D) => Promise - ).call(this, data); - - // Validate that the processed data has compatible type with original data - if ( - processedData !== undefined && - this.isCompatibleType(processedData, data) - ) { - return processedData; - } - } + // Type-safe optional hook methods for post-processing + protected postAll?( + result: Partial, + ): Promise>; - return data; - } + protected postCount?(result: number): Promise; protected async postCreate(result: T): Promise { return result; } + // protected postCreate?(result: T): Promise; + protected postDelete?(result: T): Promise; + + protected postFind?(result: readonly T[]): Promise; + + protected postFindById?(result: T): Promise; + + protected postFindOne?(result: T): Promise; + + protected postList?(result: PaginatedList): Promise>; + protected async postProcess(action: string, result: R): Promise { const postHook = this.getHook("post", action); @@ -311,6 +268,50 @@ abstract class BaseService< return result; } + + protected postUpdate?(result: T): Promise; + + // Type-safe optional hook methods for pre-processing + protected preAll?(): Promise; + + protected preCount?(): Promise; + + protected preCreate?(data: C): Promise; + + protected preDelete?(id: number | string): Promise; + + protected preFind?(): Promise; + + protected preFindById?(id: number | string): Promise; + + protected preFindOne?(): Promise; + + protected preList?(): Promise; + + protected async preProcess( + action: string, + data?: D, + ): Promise { + const preHook = this.getHook("pre", action); + + if (typeof preHook === "function") { + const processedData = await ( + preHook as (data?: D) => Promise + ).call(this, data); + + // Validate that the processed data has compatible type with original data + if ( + processedData !== undefined && + this.isCompatibleType(processedData, data) + ) { + return processedData; + } + } + + return data; + } + + protected preUpdate?(data: U): Promise; } export default BaseService; diff --git a/packages/slonik/src/slonik.ts b/packages/slonik/src/slonik.ts index f78e1a111..9fc5c79ef 100644 --- a/packages/slonik/src/slonik.ts +++ b/packages/slonik/src/slonik.ts @@ -1,20 +1,21 @@ +import type { FastifyInstance } from "fastify"; +import type { ClientConfigurationInput } from "slonik"; + // [OP 2023-JAN-28] Copy/pasted from https://github.com/spa5k/fastify-slonik/blob/main/src/index.ts import fastifyPlugin from "fastify-plugin"; import { sql } from "slonik"; -import createDatabase from "./createDatabase"; - import type { Database } from "./types"; -import type { FastifyInstance } from "fastify"; -import type { ClientConfigurationInput } from "slonik"; + +import createDatabase from "./createDatabase"; type SlonikOptions = { - connectionString: string; clientConfiguration?: ClientConfigurationInput; + connectionString: string; }; const plugin = async (fastify: FastifyInstance, options: SlonikOptions) => { - const { connectionString, clientConfiguration } = options; + const { clientConfiguration, connectionString } = options; let db: Database; try { diff --git a/packages/slonik/src/sql.ts b/packages/slonik/src/sql.ts index 23ddd06a0..eda04bc6c 100644 --- a/packages/slonik/src/sql.ts +++ b/packages/slonik/src/sql.ts @@ -1,15 +1,16 @@ -import humps from "humps"; -import { sql } from "slonik"; - -import { applyFiltersToQuery, buildFilterFragment } from "./filters"; - -import type { FilterInput, SortInput } from "./types"; import type { FragmentSqlToken, IdentifierSqlToken, ValueExpression, } from "slonik"; +import humps from "humps"; +import { sql } from "slonik"; + +import type { FilterInput, SortInput } from "./types"; + +import { applyFiltersToQuery, buildFilterFragment } from "./filters"; + const createFilterFragment = ( filters: FilterInput | undefined, tableIdentifier: IdentifierSqlToken, diff --git a/packages/slonik/src/sqlFactory.ts b/packages/slonik/src/sqlFactory.ts index 4bab9f58f..7ffa1237e 100644 --- a/packages/slonik/src/sqlFactory.ts +++ b/packages/slonik/src/sqlFactory.ts @@ -1,7 +1,22 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { + FragmentSqlToken, + IdentifierSqlToken, + QuerySqlToken, +} from "slonik"; + import humps from "humps"; import { sql } from "slonik"; import { z } from "zod"; +import type { + Database, + FilterInput, + SortDirection, + SortInput, + SqlFactory, +} from "./types"; + import { createFilterFragment, createLimitFragment, @@ -12,34 +27,74 @@ import { isValueExpression, } from "./sql"; -import type { - Database, - FilterInput, - SqlFactory, - SortInput, - SortDirection, -} from "./types"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; -import type { - FragmentSqlToken, - IdentifierSqlToken, - QuerySqlToken, -} from "slonik"; - class DefaultSqlFactory implements SqlFactory { - static readonly TABLE = undefined as unknown as string; static readonly LIMIT_DEFAULT: number = 20; static readonly LIMIT_MAX: number = 50; static readonly SORT_DIRECTION: SortDirection = "ASC"; static readonly SORT_KEY: string = "id"; + static readonly TABLE = undefined as unknown as string; + + get config(): ApiConfig { + return this._config; + } + get database(): Database { + return this._database; + } + get limitDefault(): number { + return ( + this.config.slonik?.pagination?.defaultLimit || + (this.constructor as typeof DefaultSqlFactory).LIMIT_DEFAULT + ); + } + get limitMax(): number { + return ( + this.config.slonik?.pagination?.maxLimit || + (this.constructor as typeof DefaultSqlFactory).LIMIT_MAX + ); + } + get schema(): string { + return this._schema || "public"; + } + get softDeleteEnabled(): boolean { + return this._softDeleteEnabled; + } + + get sortDirection(): SortDirection { + return (this.constructor as typeof DefaultSqlFactory).SORT_DIRECTION; + } + + get sortKey(): string { + return (this.constructor as typeof DefaultSqlFactory).SORT_KEY; + } + + get table(): string { + return (this.constructor as typeof DefaultSqlFactory).TABLE; + } + + get tableFragment(): FragmentSqlToken { + return createTableFragment(this.table, this.schema); + } + + get tableIdentifier(): IdentifierSqlToken { + return createTableIdentifier(this.table); + } + + get validationSchema(): z.ZodTypeAny { + return this._validationSchema || z.any(); + } protected _config: ApiConfig; + protected _database: Database; + protected _factory: SqlFactory | undefined; + protected _schema = "public"; - protected _validationSchema: z.ZodTypeAny = z.any(); + protected _softDeleteEnabled: boolean = false; + protected _validationSchema: z.ZodTypeAny = z.any(); + constructor(config: ApiConfig, database: Database, schema?: string) { this._config = config; this._database = database; @@ -200,92 +255,10 @@ class DefaultSqlFactory implements SqlFactory { `; } - get config(): ApiConfig { - return this._config; - } - - get database(): Database { - return this._database; - } - - get limitDefault(): number { - return ( - this.config.slonik?.pagination?.defaultLimit || - (this.constructor as typeof DefaultSqlFactory).LIMIT_DEFAULT - ); - } - - get limitMax(): number { - return ( - this.config.slonik?.pagination?.maxLimit || - (this.constructor as typeof DefaultSqlFactory).LIMIT_MAX - ); - } - - get schema(): string { - return this._schema || "public"; - } - - get sortDirection(): SortDirection { - return (this.constructor as typeof DefaultSqlFactory).SORT_DIRECTION; - } - - get sortKey(): string { - return (this.constructor as typeof DefaultSqlFactory).SORT_KEY; - } - - get table(): string { - return (this.constructor as typeof DefaultSqlFactory).TABLE; - } - - get tableFragment(): FragmentSqlToken { - return createTableFragment(this.table, this.schema); - } - - get tableIdentifier(): IdentifierSqlToken { - return createTableIdentifier(this.table); - } - - get validationSchema(): z.ZodTypeAny { - return this._validationSchema || z.any(); - } - - get softDeleteEnabled(): boolean { - return this._softDeleteEnabled; - } - protected getAdditionalFilterFragments(): FragmentSqlToken[] { return []; } - protected getWhereFragment(options?: { - filters?: FilterInput; - filterFragment?: FragmentSqlToken; - includeSoftDelete?: boolean; - tableIdentifier?: IdentifierSqlToken; - }): FragmentSqlToken { - const { - filters, - includeSoftDelete = true, - filterFragment, - tableIdentifier = this.tableIdentifier, - } = options || {}; - - const fragments: FragmentSqlToken[] = []; - - if (filterFragment) { - fragments.push(filterFragment); - } - - if (includeSoftDelete && this.softDeleteEnabled) { - fragments.push(sql.fragment`${this.tableIdentifier}.deleted_at IS NULL`); - } - - fragments.push(...this.getAdditionalFilterFragments()); - - return this.getCreateWhereFragment(tableIdentifier, filters, fragments); - } - protected getCreateWhereFragment( tableIdentifier: IdentifierSqlToken, filters: FilterInput | undefined, @@ -323,12 +296,40 @@ class DefaultSqlFactory implements SqlFactory { return ( sort || [ { - key: this.sortKey, direction: this.sortDirection, + key: this.sortKey, }, ] ); } + + protected getWhereFragment(options?: { + filterFragment?: FragmentSqlToken; + filters?: FilterInput; + includeSoftDelete?: boolean; + tableIdentifier?: IdentifierSqlToken; + }): FragmentSqlToken { + const { + filterFragment, + filters, + includeSoftDelete = true, + tableIdentifier = this.tableIdentifier, + } = options || {}; + + const fragments: FragmentSqlToken[] = []; + + if (filterFragment) { + fragments.push(filterFragment); + } + + if (includeSoftDelete && this.softDeleteEnabled) { + fragments.push(sql.fragment`${this.tableIdentifier}.deleted_at IS NULL`); + } + + fragments.push(...this.getAdditionalFilterFragments()); + + return this.getCreateWhereFragment(tableIdentifier, filters, fragments); + } } export default DefaultSqlFactory; diff --git a/packages/slonik/src/typeParsers/__test__/createBigintTypeParser.test.ts b/packages/slonik/src/typeParsers/__test__/createBigintTypeParser.test.ts new file mode 100644 index 000000000..36dacf487 --- /dev/null +++ b/packages/slonik/src/typeParsers/__test__/createBigintTypeParser.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { createBigintTypeParser } from "../createBigintTypeParser"; + +describe("createBigintTypeParser", () => { + it("returns a type parser with name int8", () => { + const parser = createBigintTypeParser(); + expect(parser.name).toBe("int8"); + }); + + it("parse converts a bigint string to a number", () => { + const { parse } = createBigintTypeParser(); + expect(parse("42")).toBe(42); + }); + + it("parse handles large-but-safe integer strings", () => { + const { parse } = createBigintTypeParser(); + expect(parse(String(Number.MAX_SAFE_INTEGER))).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + it("parse returns an integer, not a float", () => { + const { parse } = createBigintTypeParser(); + const result = parse("100"); + expect(Number.isInteger(result)).toBe(true); + }); +}); diff --git a/packages/slonik/src/types/config.ts b/packages/slonik/src/types/config.ts index f08da9ea2..51a546032 100644 --- a/packages/slonik/src/types/config.ts +++ b/packages/slonik/src/types/config.ts @@ -1,5 +1,7 @@ import type { ClientConfigurationInput, ConnectionOptions } from "slonik"; +type SlonikConfig = SlonikOptions; + type SlonikOptions = { clientConfiguration?: ClientConfigurationInput; db: ConnectionOptions; @@ -16,6 +18,4 @@ type SlonikOptions = { }; }; -type SlonikConfig = SlonikOptions; - export type { SlonikConfig, SlonikOptions }; diff --git a/packages/slonik/src/types/database.ts b/packages/slonik/src/types/database.ts index 723166a20..499de9a7c 100644 --- a/packages/slonik/src/types/database.ts +++ b/packages/slonik/src/types/database.ts @@ -1,30 +1,17 @@ import type { ConnectionRoutine, DatabasePool, QueryFunction } from "slonik"; -type Database = { - connect: (connectionRoutine: ConnectionRoutine) => Promise; - pool: DatabasePool; - query: QueryFunction; -}; - -type operator = - | "ct" - | "dwithin" - | "sw" - | "ew" - | "eq" - | "gt" - | "gte" - | "lte" - | "lt" - | "in" - | "bt"; - type BaseFilterInput = { + insensitive?: boolean | string; key: string; - operator: operator; not?: boolean | string; + operator: operator; value: string; - insensitive?: boolean | string; +}; + +type Database = { + connect: (connectionRoutine: ConnectionRoutine) => Promise; + pool: DatabasePool; + query: QueryFunction; }; type FilterInput = @@ -36,12 +23,25 @@ type FilterInput = OR: FilterInput[]; }; +type operator = + | "bt" + | "ct" + | "dwithin" + | "eq" + | "ew" + | "gt" + | "gte" + | "in" + | "lt" + | "lte" + | "sw"; + type SortDirection = "ASC" | "DESC"; type SortInput = { - key: string; direction: SortDirection; insensitive?: boolean | string; + key: string; }; export type { diff --git a/packages/slonik/src/types/index.ts b/packages/slonik/src/types/index.ts index 5ac58913c..687f6be9a 100644 --- a/packages/slonik/src/types/index.ts +++ b/packages/slonik/src/types/index.ts @@ -1,11 +1,11 @@ export type { SlonikConfig, SlonikOptions } from "./config"; export type { + BaseFilterInput, Database, FilterInput, SortDirection, SortInput, - BaseFilterInput, } from "./database"; export type { PaginatedList, Service } from "./service"; diff --git a/packages/slonik/src/types/service.ts b/packages/slonik/src/types/service.ts index e0306f054..1628a187a 100644 --- a/packages/slonik/src/types/service.ts +++ b/packages/slonik/src/types/service.ts @@ -1,30 +1,31 @@ -import type { Database, FilterInput, SortInput } from "./database"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { Database, FilterInput, SortInput } from "./database"; + type PaginatedList = { - totalCount: number; - filteredCount: number; data: readonly T[]; + filteredCount: number; + totalCount: number; }; interface Service { + all(fields: string[]): Promise>; config: ApiConfig; - database: Database; - schema: "public" | string; + count(filters?: FilterInput): Promise; - all(fields: string[]): Promise>; create(data: C): Promise; - delete(id: number | string, force?: boolean): Promise; + database: Database; + delete(id: number | string, force?: boolean): Promise; find(filters?: FilterInput, sort?: SortInput[]): Promise; - findById(id: number | string): Promise; - findOne(filters?: FilterInput, sort?: SortInput[]): Promise; + findById(id: number | string): Promise; + findOne(filters?: FilterInput, sort?: SortInput[]): Promise; list( limit?: number, offset?: number, filters?: FilterInput, sort?: SortInput[], ): Promise>; - count(filters?: FilterInput): Promise; + schema: "public" | string; update(id: number | string, data: U): Promise; } diff --git a/packages/slonik/src/types/sqlFactory.ts b/packages/slonik/src/types/sqlFactory.ts index 50b48d309..3bf2fa1b5 100644 --- a/packages/slonik/src/types/sqlFactory.ts +++ b/packages/slonik/src/types/sqlFactory.ts @@ -1,4 +1,3 @@ -import type { Database, FilterInput, SortInput } from "../types"; import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { FragmentSqlToken, @@ -6,22 +5,18 @@ import type { QuerySqlToken, } from "slonik"; +import type { Database, FilterInput, SortInput } from "../types"; + interface SqlFactory { config: ApiConfig; database: Database; - limitDefault: number; - limitMax: number; - schema: "public" | string; - table: string; - tableFragment: FragmentSqlToken; - tableIdentifier: IdentifierSqlToken; - getAllSql(fields: string[], sort?: SortInput[]): QuerySqlToken; getCountSql(filters?: FilterInput): QuerySqlToken; getCreateSql(data: Record): QuerySqlToken; getDeleteSql(id: number | string, force?: boolean): QuerySqlToken; getFindByIdSql(id: number | string): QuerySqlToken; getFindOneSql(filters?: FilterInput, sort?: SortInput[]): QuerySqlToken; + getFindSql(filters?: FilterInput, sort?: SortInput[]): QuerySqlToken; getListSql( limit?: number, @@ -34,6 +29,12 @@ interface SqlFactory { id: number | string, data: Record, ): QuerySqlToken; + limitDefault: number; + limitMax: number; + schema: "public" | string; + table: string; + tableFragment: FragmentSqlToken; + tableIdentifier: IdentifierSqlToken; } export type { SqlFactory }; diff --git a/packages/slonik/tsconfig.json b/packages/slonik/tsconfig.json index 8a8ad62d0..1628077b9 100644 --- a/packages/slonik/tsconfig.json +++ b/packages/slonik/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "./dist", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/slonik/vite.config.ts b/packages/slonik/vite.config.ts index 201183dcb..2e5001c4e 100644 --- a/packages/slonik/vite.config.ts +++ b/packages/slonik/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; diff --git a/packages/swagger/FEATURES.md b/packages/swagger/FEATURES.md new file mode 100644 index 000000000..7df81279f --- /dev/null +++ b/packages/swagger/FEATURES.md @@ -0,0 +1,31 @@ + + +## Plugin Registration + +1. Registers `@fastify/swagger` and `@fastify/swagger-ui` together as a single plugin using one unified options object. +2. Wrapped with `fastify-plugin` so decorators and routes escape encapsulation and are available to the parent scope. + +## Configuration + +3. Accepts a single `SwaggerOptions` object with three fields: `fastifySwaggerOptions` (required), `uiOptions` (optional), and `enabled` (optional). +4. `enabled` flag (`boolean`, optional) — when explicitly `false`, skips all plugin registration; no child plugins are registered and no decorators are added. + + ```typescript + await fastify.register(swaggerPlugin, { + enabled: false, + fastifySwaggerOptions: { openapi: {} }, + }); + // fastify.swagger, fastify.swaggerUIRoutePrefix, fastify.apiDocumentationPath → all undefined + ``` + +5. `uiOptions` defaults to `{}` when omitted — `@fastify/swagger-ui` is always registered (with its own defaults) unless `enabled` is `false`. + +## Fastify Instance Decorators + +6. Decorates `fastify.swaggerUIRoutePrefix` with the value of `uiOptions.routePrefix`, falling back to `"/documentation"` when `uiOptions` is omitted or `routePrefix` is not set. +7. Decorates `fastify.apiDocumentationPath` with the same value as `swaggerUIRoutePrefix` (both always resolve to the same string). +8. Both decorators are typed on `FastifyInstance` via a module augmentation as `string | undefined` — they are `undefined` when `enabled` is `false`. + +## Type Exports + +9. Exports the `SwaggerOptions` type for consumers to type their own configuration objects. diff --git a/packages/swagger/GUIDE.md b/packages/swagger/GUIDE.md new file mode 100644 index 000000000..9e13a6036 --- /dev/null +++ b/packages/swagger/GUIDE.md @@ -0,0 +1,390 @@ +# @prefabs.tech/fastify-swagger — Developer Guide + +## Installation + +### For package consumers (npm + pnpm) + +```bash +# npm +npm install @prefabs.tech/fastify-swagger fastify fastify-plugin + +# pnpm +pnpm add @prefabs.tech/fastify-swagger fastify fastify-plugin +``` + +### For monorepo development (pnpm install / test / build) + +```bash +# from the repo root +pnpm install + +# run tests for this package only +pnpm --filter @prefabs.tech/fastify-swagger test + +# build +pnpm --filter @prefabs.tech/fastify-swagger build +``` + +## Setup + +The plugin registers both `@fastify/swagger` (spec generation) and `@fastify/swagger-ui` (UI serving) in one call. `fastifySwaggerOptions` is the only required field. + +```typescript +import Fastify from "fastify"; +import swaggerPlugin, { + type SwaggerOptions, +} from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify({ logger: true }); + +const swaggerConfig: SwaggerOptions = { + fastifySwaggerOptions: { + openapi: { + info: { + title: "My API", + version: "1.0.0", + description: "Public API documentation", + }, + }, + }, + uiOptions: { + routePrefix: "/docs", + }, +}; + +await fastify.register(swaggerPlugin, swaggerConfig); +await fastify.ready(); + +// Decorators are now available: +console.log(fastify.swaggerUIRoutePrefix); // "/docs" +console.log(fastify.apiDocumentationPath); // "/docs" + +await fastify.listen({ port: 3000 }); +``` + +All later examples assume this setup is already in place unless stated otherwise. + +--- + +## Base Libraries + +### `@fastify/swagger` — Full Passthrough + +Their docs: https://www.npmjs.com/package/@fastify/swagger + +This plugin passes the `fastifySwaggerOptions` value directly to `fastify.register(fastifySwagger, fastifySwaggerOptions)` without modification. Every option documented by `@fastify/swagger` (OpenAPI / Swagger 2 spec config, `mode`, `transform`, `refResolver`, etc.) is fully supported. We add nothing on top of `@fastify/swagger`'s behaviour. + +### `@fastify/swagger-ui` — Full Passthrough + +Their docs: https://www.npmjs.com/package/@fastify/swagger-ui + +`uiOptions` is passed directly to `fastify.register(swaggerUi, uiOptions ?? {})` without modification. Every option documented by `@fastify/swagger-ui` (`routePrefix`, `uiConfig`, `logo`, `theme`, `staticCSP`, etc.) is fully supported. The only behaviour we add is reading `uiOptions.routePrefix` to populate our own decorators (see below). + +--- + +## Features + +### 1. Single-plugin registration for swagger + swagger-ui (Feature 1) + +One `fastify.register` call installs both `@fastify/swagger` and `@fastify/swagger-ui`, keeping your bootstrap code concise. + +```typescript +// Before (without this wrapper) +await fastify.register(fastifySwagger, { + openapi: { info: { title: "API", version: "1" } }, +}); +await fastify.register(swaggerUi, { routePrefix: "/docs" }); + +// After (with this wrapper) +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: { info: { title: "API", version: "1" } } }, + uiOptions: { routePrefix: "/docs" }, +}); +``` + +### 2. Plugin scope escaping via `fastify-plugin` (Feature 2) + +The plugin is wrapped with `fastify-plugin`, which disables Fastify's encapsulation boundary. Decorators and routes added by `@fastify/swagger` and `@fastify/swagger-ui` — as well as the decorators this plugin adds — are visible to the parent scope without needing to hoist the registration. + +```typescript +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, +}); +await fastify.ready(); + +// Available on the root instance, not just inside a child scope: +fastify.swagger(); // works +fastify.swaggerUIRoutePrefix; // works +``` + +### 3. Unified `SwaggerOptions` configuration type (Feature 3) + +A single typed options object groups all configuration in one place. `fastifySwaggerOptions` is required; `uiOptions` and `enabled` are optional. + +```typescript +import type { SwaggerOptions } from "@prefabs.tech/fastify-swagger"; + +const config: SwaggerOptions = { + // Required — passed straight to @fastify/swagger + fastifySwaggerOptions: { + openapi: { + info: { title: "My API", version: "2.0.0" }, + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer" }, + }, + }, + }, + }, + // Optional — passed straight to @fastify/swagger-ui + uiOptions: { + routePrefix: "/docs", + uiConfig: { docExpansion: "list" }, + }, + // Optional — set to false to disable entirely + enabled: true, +}; +``` + +### 4. `enabled` flag to disable all swagger registration (Feature 4) + +Setting `enabled: false` causes the plugin to exit immediately after logging an info message. Neither `@fastify/swagger` nor `@fastify/swagger-ui` is registered, and no decorators are added to the instance. This is useful for disabling API docs in production builds without changing your registration code. + +```typescript +const isProduction = process.env.NODE_ENV === "production"; + +await fastify.register(swaggerPlugin, { + enabled: !isProduction, + fastifySwaggerOptions: { + openapi: { info: { title: "API", version: "1.0.0" } }, + }, + uiOptions: { routePrefix: "/docs" }, +}); + +await fastify.ready(); + +if (isProduction) { + // None of these are defined: + console.log(fastify.swaggerUIRoutePrefix); // undefined + console.log(fastify.apiDocumentationPath); // undefined + console.log(fastify.swagger); // undefined +} +``` + +When `enabled` is omitted or is any value other than `false`, registration proceeds normally. + +### 5. `uiOptions` defaults to `{}` when omitted (Feature 5) + +If `uiOptions` is not supplied, `@fastify/swagger-ui` is still registered with an empty options object, picking up its own defaults (UI served at `/documentation`). + +```typescript +// uiOptions omitted — UI still served at /documentation +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, +}); +await fastify.ready(); + +// Confirmed by the decorator value: +console.log(fastify.swaggerUIRoutePrefix); // "/documentation" +``` + +### 6. `fastify.swaggerUIRoutePrefix` decorator (Feature 6) + +After the plugin is ready, `fastify.swaggerUIRoutePrefix` holds the route prefix under which the Swagger UI is served. The value comes from `uiOptions.routePrefix`; if that is absent, it defaults to `"/documentation"`. + +```typescript +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + uiOptions: { routePrefix: "/api-docs" }, +}); +await fastify.ready(); + +console.log(fastify.swaggerUIRoutePrefix); // "/api-docs" + +// Use it to construct links or redirect logic: +fastify.get("/", async (_req, reply) => { + reply.redirect(fastify.swaggerUIRoutePrefix!); +}); +``` + +### 7. `fastify.apiDocumentationPath` decorator (Feature 7) + +`fastify.apiDocumentationPath` is a second decorator set to the same value as `swaggerUIRoutePrefix`. It exists as a semantically distinct name that other plugins or application code can reference without being coupled to the "Swagger UI" concept specifically. + +```typescript +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, +}); +await fastify.ready(); + +// Both resolve identically: +console.log(fastify.swaggerUIRoutePrefix); // "/documentation" +console.log(fastify.apiDocumentationPath); // "/documentation" +``` + +### 8. Module augmentation for `FastifyInstance` types (Feature 8) + +`src/index.ts` extends the `FastifyInstance` interface so TypeScript knows about both decorators without extra type assertions. The type is `string | undefined` — `undefined` when `enabled` is `false`. + +```typescript +import type { FastifyInstance } from "fastify"; + +// No type assertion needed: +function logDocsPath(fastify: FastifyInstance) { + const path: string | undefined = fastify.apiDocumentationPath; + if (path) { + console.log(`Docs available at ${path}`); + } +} +``` + +### 9. `SwaggerOptions` type export (Feature 9) + +The `SwaggerOptions` type is re-exported from the package entry point, letting consumers type their own configuration files or factory functions without importing from internal paths. + +```typescript +import type { SwaggerOptions } from "@prefabs.tech/fastify-swagger"; + +function buildSwaggerConfig(title: string, version: string): SwaggerOptions { + return { + fastifySwaggerOptions: { + openapi: { info: { title, version } }, + }, + }; +} +``` + +--- + +## Use Cases + +### Serving interactive API docs during development only + +Disable Swagger in production to avoid exposing internal API structure, while keeping it active in development and staging. + +```typescript +import Fastify from "fastify"; +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(swaggerPlugin, { + enabled: process.env.NODE_ENV !== "production", + fastifySwaggerOptions: { + openapi: { + info: { title: "Internal API", version: "1.0.0" }, + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer" }, + }, + }, + }, + }, + uiOptions: { + routePrefix: "/docs", + uiConfig: { persistAuthorization: true }, + }, +}); + +await fastify.listen({ port: 3000 }); +``` + +### Redirecting the root path to API docs + +Use `apiDocumentationPath` to wire a root-level redirect without hardcoding the docs path in multiple places. + +```typescript +import Fastify from "fastify"; +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify({ logger: true }); + +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { + openapi: { info: { title: "My API", version: "1.0.0" } }, + }, + uiOptions: { routePrefix: "/docs" }, +}); + +// After ready(), apiDocumentationPath is guaranteed to be set +await fastify.ready(); + +fastify.get("/", async (_req, reply) => { + reply.redirect(fastify.apiDocumentationPath!); +}); + +await fastify.listen({ port: 3000 }); +``` + +### Annotating routes and generating an OpenAPI spec + +Register routes with JSON Schema annotations and retrieve the generated OpenAPI document via `fastify.swagger()` (provided by `@fastify/swagger`). + +```typescript +import Fastify from "fastify"; +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify(); + +// Route registered before or after plugin — both work +fastify.get( + "/users/:id", + { + schema: { + params: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + response: { + 200: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + }, + }, + }, + }, + }, + async (req) => ({ id: req.params.id, name: "Alice" }), +); + +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { + openapi: { info: { title: "Users API", version: "1.0.0" } }, + }, +}); + +await fastify.ready(); + +const spec = fastify.swagger(); +console.log(JSON.stringify(spec, null, 2)); +// Outputs fully generated OpenAPI 3.x document +``` + +### Sharing the docs route prefix across the application + +Expose `swaggerUIRoutePrefix` via an application-level config endpoint so clients can discover where docs are served. + +```typescript +import Fastify from "fastify"; +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; + +const fastify = Fastify(); + +await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { + openapi: { info: { title: "API", version: "1.0.0" } }, + }, + uiOptions: { routePrefix: "/api-docs" }, +}); + +await fastify.ready(); + +fastify.get("/meta", async () => ({ + docsUrl: fastify.swaggerUIRoutePrefix, +})); + +// GET /meta → { "docsUrl": "/api-docs" } +``` diff --git a/packages/swagger/README.md b/packages/swagger/README.md index 9824a8322..2fdb9c6a4 100644 --- a/packages/swagger/README.md +++ b/packages/swagger/README.md @@ -2,6 +2,16 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of swagger in fastify API. +## Why this plugin? + +In any moderately sized back-end application, maintaining OpenAPI documentation manually inevitably leads to discrepancies between actual route logic and API docs. This plugin seamlessly exposes a beautifully rendered Swagger interface out of your existing Fastify routes. We created this plugin to: + +- **Automate API Documentation**: Seamlessly parse existing Fastify JSON-schemas bound to your HTTP routes and instantly render a polished Swagger UI portal without dedicating extra engineering hours to manual documentation writing. +- **Integrate Global Configuration**: Effortlessly hook the swagger rendering preferences, base paths, and API spec metadata natively via our unified `@prefabs.tech/fastify-config` ecosystem. + +### Design Decisions: Why wrap @fastify/swagger? + +- **Config Standardization**: Wrapping `@fastify/swagger` and `@fastify/swagger-ui` behind our unified plugin mechanism forces the Swagger metadata configurations to match our strict standard monorepo constraints. Instead of repeating fastify setup boilerplate configurations in every sub-service, developers can instantiate beautifully documented APIs using a strictly typed, one-line configuration object. ## Installation @@ -14,10 +24,11 @@ npm install @prefabs.tech/fastify-swagger Install with pnpm: ```bash -pnpm add --filter "@scope/project @prefabs.tech/fastify-swagger +pnpm add --filter "@scope/project" @prefabs.tech/fastify-swagger ``` ## Configuration + To configure the swagger, add the following settings to your `config/swagger.ts` file: ```typescript @@ -32,11 +43,11 @@ const swaggerConfig: SwaggerOptions = { version: "1.0.0", }, servers: [ - { - url: 'http://localhost:3000', - description: 'Development server' - } - ], + { + description: "Development server", + url: "http://localhost:3000", + }, + ], }, }, }; @@ -50,22 +61,21 @@ Register the plugin with your Fastify instance: ```typescript import Fastify from "fastify"; -import swaggerPlugin from "@prefabs.tech/fastify-swagger" +import swaggerPlugin from "@prefabs.tech/fastify-swagger"; -import swaggerConfig from "./config/swagger" +import swaggerConfig from "./config/swagger"; const start = async () => { // Create fastify instance const fastify = Fastify(); - await fastify.register(swaggerPlugin, swaggerOptions); + await fastify.register(swaggerPlugin, swaggerConfig); await fastify.listen({ - port: 3000, host: "0.0.0.0", + port: 3000, }); }; start(); ``` - diff --git a/packages/swagger/__test__/plugin.test.ts b/packages/swagger/__test__/plugin.test.ts deleted file mode 100644 index 318aa4670..000000000 --- a/packages/swagger/__test__/plugin.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import Fastify from "fastify"; -import { describe, it, expect } from "vitest"; - -import plugin from "../src/index"; - -describe("plugin", () => { - it("should be exported as default and register without issues", async () => { - const fastify = Fastify(); - - const options = { - enabled: true, - fastifySwaggerOptions: { - openapi: { - info: { - title: "Test API", - version: "1.0.0", - }, - }, - }, - }; - - await expect(fastify.register(plugin, options)).resolves.not.toThrow(); - }); - - it("should not register API documentation when enabled is set to false", async () => { - const fastify = Fastify(); - - const options = { - enabled: false, - fastifySwaggerOptions: { - openapi: { - info: { - title: "Test API", - version: "1.0.0", - }, - }, - }, - }; - - await fastify.register(plugin, options); - await fastify.ready(); - - expect(fastify.swagger).toBeUndefined(); - expect(fastify.apiDocumentationPath).toBeUndefined(); - - await fastify.close(); - }); - - it("should change the documentation path when routePrefix is modified", async () => { - const fastify = Fastify(); - - const options = { - fastifySwaggerOptions: { - openapi: { - info: { - title: "Test API", - version: "1.0.0", - }, - }, - }, - uiOptions: { - routePrefix: "/docs", - }, - }; - - await fastify.register(plugin, options); - - expect(fastify.apiDocumentationPath).toBe("/docs"); - }); -}); diff --git a/packages/swagger/package.json b/packages/swagger/package.json index 1c1ea3546..cac8fee5b 100644 --- a/packages/swagger/package.json +++ b/packages/swagger/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-swagger", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify swagger plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/swagger#readme", "repository": { @@ -32,23 +32,23 @@ }, "dependencies": { "@fastify/swagger": "9.7.0", - "@fastify/swagger-ui": "5.2.5" + "@fastify/swagger-ui": "5.2.6" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/tsconfig": "0.5.0", - "@types/node": "24.10.13", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@types/node": "24.10.15", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "prettier": "3.8.1", + "prettier": "3.8.3", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4" }, "peerDependencies": { - "fastify": ">=5.2.1", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1" }, "peerDependenciesMeta": { diff --git a/packages/swagger/src/__test__/plugin.test.ts b/packages/swagger/src/__test__/plugin.test.ts new file mode 100644 index 000000000..db2f04fe1 --- /dev/null +++ b/packages/swagger/src/__test__/plugin.test.ts @@ -0,0 +1,155 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +import swaggerPlugin from "../plugin"; + +describe("swagger plugin", () => { + let fastify: FastifyInstance; + + afterEach(async () => { + await fastify.close(); + }); + + it("registers successfully with minimal required options", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + }); + await expect(fastify.ready()).resolves.toBeDefined(); + }); + + it("decorates instance with default documentation path when uiOptions not provided", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/documentation"); + expect(fastify.apiDocumentationPath).toBe("/documentation"); + }); + + it("decorates instance with custom routePrefix from uiOptions", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + uiOptions: { routePrefix: "/api-docs" }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/api-docs"); + expect(fastify.apiDocumentationPath).toBe("/api-docs"); + }); + + it("uses default documentation path when uiOptions is an empty object", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + uiOptions: {}, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/documentation"); + expect(fastify.apiDocumentationPath).toBe("/documentation"); + }); + + it("serves swagger spec JSON at custom uiOptions.routePrefix", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + uiOptions: { routePrefix: "/api-docs" }, + }); + await fastify.ready(); + + const response = await fastify.inject({ + method: "GET", + url: "/api-docs/json", + }); + + expect(response.statusCode).toBe(200); + }); + + it("skips registration when enabled is false", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + enabled: false, + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBeUndefined(); + expect(fastify.apiDocumentationPath).toBeUndefined(); + expect(fastify.swagger).toBeUndefined(); + + const response = await fastify.inject({ + method: "GET", + url: "/documentation/json", + }); + expect(response.statusCode).toBe(404); + }); + + it("registers when enabled is explicitly true", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + enabled: true, + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/documentation"); + }); + + it("registers when enabled is undefined (default behavior)", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + expect(fastify.swaggerUIRoutePrefix).toBe("/documentation"); + }); + + it("passes uiOptions through to swagger-ui (serves UI route)", async () => { + fastify = Fastify(); + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { openapi: {} }, + }); + await fastify.ready(); + + const response = await fastify.inject({ + method: "GET", + url: "/documentation/json", + }); + + expect(response.statusCode).toBe(200); + }); + + it("passes fastifySwaggerOptions through (generates spec)", async () => { + fastify = Fastify(); + + fastify.get( + "/test", + { + schema: { + response: { + 200: { properties: { ok: { type: "boolean" } }, type: "object" }, + }, + }, + }, + async () => ({ ok: true }), + ); + + await fastify.register(swaggerPlugin, { + fastifySwaggerOptions: { + openapi: { + info: { title: "Test API", version: "1.0.0" }, + }, + }, + }); + await fastify.ready(); + + const spec = fastify.swagger(); + expect(spec.info.title).toBe("Test API"); + expect(spec.info.version).toBe("1.0.0"); + }); +}); diff --git a/packages/swagger/src/plugin.ts b/packages/swagger/src/plugin.ts index 2629439b6..4d70cec5b 100644 --- a/packages/swagger/src/plugin.ts +++ b/packages/swagger/src/plugin.ts @@ -1,9 +1,10 @@ +import type { FastifyInstance } from "fastify"; + import fastifySwagger from "@fastify/swagger"; import swaggerUi from "@fastify/swagger-ui"; import FastifyPlugin from "fastify-plugin"; import type { SwaggerOptions } from "./types"; -import type { FastifyInstance } from "fastify"; const plugin = async (fastify: FastifyInstance, options: SwaggerOptions) => { const { fastifySwaggerOptions, uiOptions } = options; diff --git a/packages/swagger/tsconfig.json b/packages/swagger/tsconfig.json index 8a8ad62d0..1628077b9 100644 --- a/packages/swagger/tsconfig.json +++ b/packages/swagger/tsconfig.json @@ -1,13 +1,9 @@ { "extends": "@prefabs.tech/tsconfig/fastify.json", - "exclude": [ - "src/**/__test__/**/*", - ], + "exclude": ["src/**/__test__/**/*"], "compilerOptions": { "baseUrl": "./", - "outDir": "./dist", + "outDir": "./dist" }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/swagger/vite.config.ts b/packages/swagger/vite.config.ts index f5da6a8aa..381bcd46f 100644 --- a/packages/swagger/vite.config.ts +++ b/packages/swagger/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -25,10 +24,10 @@ export default defineConfig(({ mode }) => { output: { exports: "named", globals: { - fastify: "Fastify", - "fastify-plugin": "FastifyPlugin", "@fastify/swagger": "FastifySwagger", "@fastify/swagger-ui": "FastifySwaggerUI", + fastify: "Fastify", + "fastify-plugin": "FastifyPlugin", }, }, }, diff --git a/packages/user/FEATURES.md b/packages/user/FEATURES.md new file mode 100644 index 000000000..a13d1e872 --- /dev/null +++ b/packages/user/FEATURES.md @@ -0,0 +1,198 @@ + + +# @prefabs.tech/fastify-user — Features + +## Plugin Lifecycle + +1. **Configurable route prefix** — all route modules are registered under `config.user.routePrefix`. + +2. **Selective route module disabling** — each of the four route groups (`users`, `invitations`, `roles`, `permissions`) can be disabled independently via `routes..disabled = true`. The service layer is unaffected. + +3. **Automatic database migrations** — on registration, runs `CREATE TABLE IF NOT EXISTS` for the `users` and `invitations` tables before the server is ready. + +4. **Default role seeding** — on `onReady`, seeds `ADMIN`, `SUPERADMIN`, and `USER` into SuperTokens, plus any extra roles listed in `config.user.roles`. + +## Authentication + +5. **`fastify.verifySession()` decorator** — added to the Fastify instance; use it as a `preHandler` to require a valid SuperTokens session on any route. + +6. **`req.session` request property** — `FastifyRequest` is augmented with an optional `session` property (populated by SuperTokens after `verifySession` runs). + +7. **`req.user` request property** — `FastifyRequest` is augmented with an optional `user: User` property, populated from the database on every verified session. + +8. **Configurable refresh-token cookie path** — an `onSend` hook rewrites the `Path` attribute of the `sRefreshToken` cookie to the value of `config.user.supertokens.refreshTokenCookiePath`, so the refresh token is scoped to the refresh endpoint. + +9. **`SUPERTOKENS_CORS_HEADERS` constant** — exports the eight SuperTokens-specific request headers that must be included in `allowedHeaders` when registering `@fastify/cors`: + + ``` + anti-csrf, authorization, fdi-version, front-token, + rid, st-access-token, st-auth-mode, st-refresh-token + ``` + +10. **SuperTokens error handler auto-registration** — automatically calls `fastify.setErrorHandler(supertokensErrorHandler)` unless `config.user.supertokens.setErrorHandler === false`. + +11. **`supertokensErrorHandler` export** — exported for manual wiring when auto-registration is disabled. + +12. **Session recipe override via function factory** — each SuperTokens recipe (`session`, `thirdPartyEmailPassword`, `userRoles`, `emailVerification`) can be overridden by supplying a function `(fastify) => RecipeConfig` under `config.user.supertokens.recipes`. The function receives the Fastify instance, enabling access to config and decorators. Providing an object instead of a function merges the object into the default config. + +13. **Override merging for `apis` and `functions`** — when a recipe override includes `override.apis` or `override.functions`, each key is called as `fn(originalImplementation, fastify)` and merged on top of the default implementation, so only the keys you provide are replaced. + +14. **Email verification (opt-in)** — setting `config.user.features.signUp.emailVerification = true` adds the `EmailVerification` recipe and enforces the email-verified claim on protected routes. Default: `false`. + +15. **Third-party OAuth providers** — Apple, Facebook, GitHub, and Google providers are configurable via `config.user.supertokens.providers`; custom providers are supported via `providers.custom`. + +## User Management + +16. **`GET /me`** — returns the authenticated user's profile. If a photo exists, the `photo.url` field is a pre-signed S3 URL. Session claims (email verification, profile validation) are bypassed so users can always read their own data. + +17. **`PUT /me`** — updates mutable fields on the current user's profile. Session claims are bypassed. + +18. **`POST /change-email`** — updates the authenticated user's email address. Gated by `config.user.features.updateEmail.enabled`. Session email-verification claims are bypassed on this route. + +19. **`POST /change_password`** — validates the current password before updating. Requires a valid session. + +20. **`DELETE /me` with atomic session revocation** — soft-deletes the user record (`deleted_at`) and immediately revokes all active SuperTokens sessions in the same operation. Requires password confirmation. + +21. **`PUT /me/photo`** — accepts `multipart/form-data`, validates MIME type (`image/jpeg`, `image/png`, `image/webp`) and file size, uploads to `{userId}/photo` in the configured S3 bucket, and links the file record to the user. Session claims bypassed. + +22. **`DELETE /me/photo`** — deletes the photo from S3 and unlinks it from the user record. Session claims bypassed. + +23. **Configurable photo size limit** — `config.user.photoMaxSizeInMB` (default: `5`). + +24. **`POST /signup/admin`** — public endpoint to create the first administrator account without an invitation. + +25. **`GET /signup/admin`** — public endpoint returning `{ signUp: boolean }` indicating whether admin sign-up is currently available. + +26. **`GET /users`** — paginatable list of all users. Requires `users:list` permission. + +27. **`GET /users/:id`** — fetches a single user by ID. Requires `users:read` permission. + +28. **`PUT /users/:id/disable`** — sets the user's `disabled` flag to `true`. Requires `users:disable` permission. + +29. **`PUT /users/:id/enable`** — clears the user's `disabled` flag. Requires `users:enable` permission. + +30. **Immutable field guard (`filterUserUpdateInput`)** — applied automatically before every profile update; silently drops any attempt to set `id`, `email`, `roles`, `lastLoginAt`, `signedUpAt`, `disable`, or `enable`. Handles both camelCase and snake_case variants (e.g. `last_login_at` is also stripped). + +31. **Configurable table names** — `config.user.tables.users.name` and `config.user.tables.invitations.name` override the default table names. + +32. **Custom request handlers** — every route handler can be replaced via `config.user.handlers.user.` or `config.user.handlers.invitation.`. + +## Authorization + +33. **`fastify.hasPermission(permission)` decorator** — added to the Fastify instance; returns a `preHandler` that checks the authenticated user holds the given permission. Returns 401 without a session, 403 without the permission. + +34. **`hasUserPermission(fastify, userId, permission)` utility** — programmatic permission check; returns a boolean. + +35. **SUPERADMIN bypass** — users with the `SUPERADMIN` role pass all `hasPermission` and `hasUserPermission` checks automatically, without being explicitly granted every permission. + +36. **Built-in permission constants** — pre-defined strings to avoid typos: + + ``` + PERMISSIONS_INVITATIONS_CREATE → "invitations:create" + PERMISSIONS_INVITATIONS_DELETE → "invitations:delete" + PERMISSIONS_INVITATIONS_LIST → "invitations:list" + PERMISSIONS_INVITATIONS_RESEND → "invitations:resend" + PERMISSIONS_INVITATIONS_REVOKE → "invitations:revoke" + PERMISSIONS_USERS_DISABLE → "users:disable" + PERMISSIONS_USERS_ENABLE → "users:enable" + PERMISSIONS_USERS_LIST → "users:list" + PERMISSIONS_USERS_READ → "users:read" + ``` + +37. **Application-defined custom permissions** — `config.user.permissions` registers additional permission strings returned by `GET /permissions`, making them discoverable by role-management UIs. + +## Roles + +38. **Built-in role constants** — `ROLE_ADMIN`, `ROLE_SUPERADMIN`, `ROLE_USER` are exported. + +39. **`POST /roles`** — creates a new role with optional initial permissions. Requires a valid session. + +40. **`DELETE /roles`** — deletes a role; returns `ROLE_IN_USE` error if any user holds it. Requires a valid session. + +41. **`GET /roles`** — returns all roles with their permissions. Requires a valid session. + +42. **`GET /roles/permissions`** — returns the permissions for a named role. Requires a valid session. + +43. **`PUT /roles/permissions`** — replaces the permission set of a named role. Requires a valid session. + +44. **`isRoleExists(name)` / `areRolesExist(names)` utilities** — programmatic existence checks against SuperTokens. + +## Invitations + +45. **`POST /invitations`** — creates an invitation record, validates the target email and role, checks for a duplicate pending invitation, and sends the invitation email. Requires `invitations:create` permission. + +46. **Configurable invitation expiry** — `config.user.invitation.expireAfterInDays` sets how long an invitation is valid (default: `30`). + +47. **Configurable accept link path** — `config.user.invitation.acceptLinkPath` sets the front-end path embedded in the invitation email (default: `"/signup/token/:token"`). The `:token` placeholder is replaced with the actual token. + +48. **`GET /invitations/token/:token`** — public endpoint returning the invitation record for UI display before acceptance. + +49. **`POST /invitations/token/:token`** — public endpoint that validates the invitation, creates a SuperTokens account, opens a session, and optionally calls `config.user.invitation.postAccept(request, invitation, user)`. + +50. **`GET /invitations`** — paginatable list of all invitations. Requires `invitations:list` permission. + +51. **`PUT /invitations/revoke/:id`** — marks an invitation as revoked. Requires `invitations:revoke` permission. + +52. **`POST /invitations/resend/:id`** — re-sends the invitation email. Requires `invitations:resend` permission. + +53. **`DELETE /invitations/:id`** — permanently removes an invitation record. Requires `invitations:delete` permission. + +54. **`isInvitationValid(invitation)` utility** — returns `true` only when the invitation is pending, non-expired, non-revoked, and non-accepted. + +55. **`computeInvitationExpiresAt(config, explicitDate?)` utility** — computes the expiry timestamp using the configured `expireAfterInDays`, or returns `explicitDate` when provided. + +56. **`getOrigin(url)` utility** — extracts `scheme://host[:non-default-port]` from a URL string. Returns an empty string for bare hostnames, IP addresses without a scheme, relative paths, or any input that is not a full URL. Default ports (`80` / `443`) are stripped. + +57. **`sendInvitation(fastify, invitation, origin)` utility** — sends the invitation email; usable from custom code that bypasses the REST route. + +## Email + +58. **`validateEmail(email, config)` utility** — validates an email string against `config.user.email` options using `validator.js`. Returns `{ success: true }` or `{ success: false, message }`. Gracefully falls back to permissive defaults when no email config is provided. + +59. **Email domain whitelist / blacklist** — `config.user.email.host_whitelist` and `config.user.email.host_blacklist` restrict which domains are accepted during sign-up and invitation. + +60. **Custom email subjects and templates** — `config.user.emailOverrides` overrides the subject and `templateName` for any of the five system emails: `invitation`, `resetPassword`, `resetPasswordNotification`, `emailVerification`, `duplicateEmail`. + +61. **`sendEmail(options)` utility** — sends a templated email via `fastify.mailer`; accepts `{ fastify, subject, templateName, to, templateData }`. + +62. **`verifyEmail(userId, email)` utility** — programmatically marks a user's email as verified in SuperTokens (useful for invited users who skip the verification link). + +## Password + +63. **`validatePassword(password, config)` utility** — validates password strength against `config.user.password` options. Returns `{ success: true }` or `{ success: false, message }` listing all failed requirements. + +64. **Configurable strength thresholds** — `config.user.password` accepts `minLength` (default: `8`), `minLowercase`, `minUppercase`, `minNumbers`, `minSymbols` (all default to `0` unless configured), and scoring tuning fields (`pointsPerUnique`, `pointsPerRepeat`, `pointsForContaining*`). + +## Profile Validation Claim + +65. **`ProfileValidationClaim` custom session claim** — a SuperTokens `SessionClaim` that checks whether required profile fields are populated. Re-fetched on every request. Enable via `config.user.features.profileValidation.enabled = true` and list required fields in `features.profileValidation.fields`. + +66. **Grace period** — `config.user.features.profileValidation.gracePeriodInDays` allows users to access protected resources for N days after sign-up before the claim is enforced. After the grace period, requests fail with 403. + +67. **Per-route claim opt-out** — routes that must stay accessible regardless of profile completeness can bypass the claim via `verifySession({ overrideGlobalClaimValidators: () => [] })` (REST) or `@auth(profileValidation: false)` (GraphQL). + +## GraphQL Integration + +> Requires `config.graphql.enabled = true` and `@prefabs.tech/fastify-graphql`. + +68. **MercuriusContext extended with `user` and `roles`** — `context.user: User | undefined` and `context.roles: string[] | undefined` are populated before each resolver via `plugin.updateContext`. + +69. **`@auth` directive** — protects a field or mutation; checks (1) authenticated session, (2) non-disabled account, (3) email verified (if enabled, unless `emailVerification: false` is passed), (4) profile complete (if enabled, unless `profileValidation: false` is passed). + +70. **`@hasPermission(permission)` directive** — enforces a named permission on a GraphQL field; SUPERADMIN bypasses automatically. + +71. **User GraphQL types** — `User`, `Photo`, `Users` (paginated wrapper with `totalCount`, `filteredCount`, `data`). + +72. **User queries** — `canAdminSignUp`, `me`, `user(id)`, `users(limit, offset, filters, sort)`. + +73. **User mutations** — `adminSignUp`, `changeEmail`, `changePassword`, `deleteMe`, `disableUser`, `enableUser`, `removePhoto`, `updateMe`, `uploadPhoto`. + +74. **Invitation GraphQL types and operations** — `Invitation` type; queries `getInvitationByToken`, `listInvitation`; mutations `acceptInvitation`, `createInvitation`, `deleteInvitation`, `resendInvitation`, `revokeInvitation`. + +75. **Role GraphQL types and operations** — `Role` type; queries `roles`, `rolePermissions`; mutations `createRole`, `deleteRole`, `updateRolePermissions`. + +76. **`permissions` GraphQL query** — returns the configured permission strings. + +77. **`userSchema` merged schema export** — the complete SDL string combining all user, invitation, role, and permission type definitions; ready to pass to `mergeTypeDefs`. + +78. **Resolver exports** — `userResolver`, `invitationResolver`, `roleResolver`, `permissionResolver` are exported individually for spreading into a larger resolver map. diff --git a/packages/user/GUIDE.md b/packages/user/GUIDE.md new file mode 100644 index 000000000..914ccf1e4 --- /dev/null +++ b/packages/user/GUIDE.md @@ -0,0 +1,827 @@ +# @prefabs.tech/fastify-user — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-user +``` + +```bash +pnpm add @prefabs.tech/fastify-user +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-user test +pnpm --filter @prefabs.tech/fastify-user build +``` + +## Setup + +This plugin requires several peer plugins registered beforehand. All subsequent examples assume this setup. + +```typescript +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import formbody from "@fastify/formbody"; +import configPlugin from "@prefabs.tech/fastify-config"; +import errorHandlerPlugin from "@prefabs.tech/fastify-error-handler"; +import userPlugin, { + SUPERTOKENS_CORS_HEADERS, +} from "@prefabs.tech/fastify-user"; + +const fastify = Fastify(); + +await fastify.register(configPlugin, { + config: { + // ...your ApiConfig... + user: { + routePrefix: "/api", + supertokens: { + connectionUri: "http://localhost:3567", + apiBasePath: "/auth", + }, + roles: ["EDITOR"], + permissions: ["posts:create", "posts:delete"], + }, + }, +}); + +await fastify.register(cors, { + allowedHeaders: SUPERTOKENS_CORS_HEADERS, + credentials: true, + origin: true, +}); +await fastify.register(formbody); +await fastify.register(errorHandlerPlugin, { + preErrorHandler: supertokensErrorHandler, // wire in the ST error handler +}); + +await fastify.register(userPlugin); +``` + +--- + +## Base Libraries + +### supertokens-node — Modified + +Provides authentication sessions, email/password sign-up/in, third-party OAuth, email verification, and role-based access. + +→ **Their docs:** [supertokens-node](https://www.npmjs.com/package/supertokens-node) + +We wrap `supertokens-node` initialization and expose a subset of its surface via `UserConfig.supertokens`. Recipe configuration can be partially or fully overridden with our merge pattern (see [Recipe overrides](#recipe-overrides)). + +**What we add on top:** database integration, user model, invitation flow, profile validation claim, `verifySession` decorator, permission middleware, GraphQL directives. + +### mercurius-auth — Modified + +Provides `@auth`-style directive authentication for Mercurius/GraphQL. + +→ **Their docs:** [mercurius-auth](https://www.npmjs.com/package/mercurius-auth) + +We register two separate `mercurius-auth` instances: one for `@auth` and one for `@hasPermission`. Both are registered automatically when `config.graphql.enabled = true`. + +**What we add on top:** session verification, email verification enforcement, profile validation enforcement, permission checks, SUPERADMIN bypass — all wired to our user model. + +--- + +## Features + +### Plugin registration + +On startup the plugin: + +1. Initializes SuperTokens and registers the Fastify SuperTokens adapter. +2. Runs `CREATE TABLE IF NOT EXISTS` for the `users` and `invitations` tables (before the server is ready). +3. Seeds built-in roles (`ADMIN`, `SUPERADMIN`, `USER`) plus any extra roles in `config.user.roles` into SuperTokens on `onReady`. +4. Registers four route groups under `config.user.routePrefix`, each independently disable-able. + +### Route prefix and selective route disabling + +All routes are registered under `config.user.routePrefix`. Any of the four route groups can be disabled: + +```typescript +user: { + routePrefix: "/api", + routes: { + invitations: { disabled: true }, + permissions: { disabled: false }, + roles: { disabled: false }, + users: { disabled: false }, + }, +} +``` + +### `fastify.verifySession()` decorator + +Protects any route with a SuperTokens session check. Use it as a `preHandler`: + +```typescript +fastify.get( + "/protected", + { + preHandler: fastify.verifySession(), + }, + async (request) => { + return { userId: request.session!.getUserId() }; + }, +); +``` + +`request.session` (type: `Session`) is populated after this hook runs. + +### `request.user` — authenticated user profile + +After `verifySession` runs, `request.user` is populated with the full `User` object from the database: + +```typescript +fastify.get( + "/me", + { + preHandler: fastify.verifySession(), + }, + async (request) => { + return request.user; // User | undefined + }, +); +``` + +### `fastify.hasPermission(permission)` decorator + +Returns a `preHandler` function. Combine with `verifySession` to require both a session and a specific permission: + +```typescript +fastify.delete( + "/posts/:id", + { + preHandler: [ + fastify.verifySession(), + fastify.hasPermission("posts:delete"), + ], + }, + handler, +); +``` + +- Returns `401` if no session. +- Returns `403` if the user lacks the permission. +- `SUPERADMIN` users bypass all permission checks. +- If the permission string is not in `config.user.permissions`, the check passes automatically. + +### `hasUserPermission(fastify, userId, permission)` utility + +Programmatic boolean permission check, for use outside route preHandlers: + +```typescript +import { hasUserPermission } from "@prefabs.tech/fastify-user"; + +const allowed = await hasUserPermission(fastify, userId, "posts:create"); +``` + +### SuperTokens error handler + +By default the plugin calls `fastify.setErrorHandler(supertokensErrorHandler)` automatically. To disable auto-registration and wire it manually (e.g. into `@prefabs.tech/fastify-error-handler`'s `preErrorHandler`): + +```typescript +import { supertokensErrorHandler } from "@prefabs.tech/fastify-user"; + +user: { + supertokens: { + setErrorHandler: false, // disable auto-registration + }, +} + +// Then wire manually: +await fastify.register(errorHandlerPlugin, { + preErrorHandler: supertokensErrorHandler, +}); +``` + +### Refresh-token cookie path + +An `onSend` hook rewrites the `Path` attribute of the `sRefreshToken` cookie, scoping the refresh token to a specific path: + +```typescript +user: { + supertokens: { + refreshTokenCookiePath: "/auth/session/refresh", + }, +} +``` + +Without this option the cookie uses SuperTokens' default path. + +### `SUPERTOKENS_CORS_HEADERS` constant + +An array of eight header names that must be included in `allowedHeaders` when registering `@fastify/cors` alongside SuperTokens: + +```typescript +import { SUPERTOKENS_CORS_HEADERS } from "@prefabs.tech/fastify-user"; + +await fastify.register(cors, { + allowedHeaders: SUPERTOKENS_CORS_HEADERS, + credentials: true, + origin: true, +}); +``` + +### Recipe overrides + +Each SuperTokens recipe can be partially or fully overridden by providing a function `(fastify) => RecipeConfig` under `config.user.supertokens.recipes`. When an object is provided instead, it is merged into the defaults. + +```typescript +user: { + supertokens: { + recipes: { + session: (fastify) => ({ + cookieDomain: fastify.config.appOrigin[0], + cookieSecure: true, + }), + }, + }, +} +``` + +For `override.apis` and `override.functions`, provide a function `(originalImpl, fastify) => partialOverride`; only the keys you return are replaced. + +### Third-party OAuth providers + +Configure Apple, Facebook, GitHub, and Google via `config.user.supertokens.providers`: + +```typescript +user: { + supertokens: { + providers: { + google: { clientId: "...", clientSecret: "..." }, + github: { clientId: "...", clientSecret: "..." }, + apple: [{ clientId: "...", keyId: "...", privateKey: "...", teamId: "..." }], + custom: [myCustomProvider], + }, + }, +} +``` + +### Email verification (opt-in) + +Enable to enforce the email-verified claim on protected routes: + +```typescript +user: { + features: { + signUp: { emailVerification: true }, + }, +} +``` + +`POST /change-email`, `GET /me`, `PUT /me`, `DELETE /me`, and `PUT/DELETE /me/photo` bypass the email-verification claim so users can still access their account while verifying. + +### Profile Validation Claim + +A custom SuperTokens session claim that checks required profile fields are populated. Enable and configure required fields: + +```typescript +user: { + features: { + profileValidation: { + enabled: true, + fields: ["photo"], // keyof UserUpdateInput + gracePeriodInDays: 7, // optional grace window after sign-up + }, + }, +} +``` + +After the grace period expires, requests to protected routes return `403` with `invalid claim` until the fields are filled. To skip the check on a specific route: + +```typescript +fastify.get( + "/onboarding", + { + preHandler: fastify.verifySession({ + overrideGlobalClaimValidators: (validators) => + validators.filter((v) => v.id !== ProfileValidationClaim.key), + }), + }, + handler, +); +``` + +### `ProfileValidationClaim` export + +Exported for use in custom route preHandlers when you need to reference the claim key directly: + +```typescript +import { ProfileValidationClaim } from "@prefabs.tech/fastify-user"; + +console.log(ProfileValidationClaim.key); // "profileValidation" +``` + +--- + +## User Routes + +All user routes are registered under `routePrefix`. The session-protected routes require `verifySession()` in their `preHandler`. + +| Method | Path | Auth | Description | +| -------- | -------------------- | --------------- | ------------------------------------- | +| `GET` | `/users` | `users:list` | Paginated user list | +| `GET` | `/users/:id` | `users:read` | Single user by ID | +| `PUT` | `/users/:id/disable` | `users:disable` | Disable a user | +| `PUT` | `/users/:id/enable` | `users:enable` | Enable a user | +| `GET` | `/me` | session | Current user's profile | +| `PUT` | `/me` | session | Update current user's profile | +| `DELETE` | `/me` | session | Soft-delete account + revoke sessions | +| `POST` | `/change-email` | session | Change email address | +| `POST` | `/change_password` | session | Change password | +| `PUT` | `/me/photo` | session | Upload profile photo (multipart) | +| `DELETE` | `/me/photo` | session | Remove profile photo | +| `POST` | `/signup/admin` | public | First-admin sign-up | +| `GET` | `/signup/admin` | public | Check admin sign-up availability | + +### Immutable field guard + +Before every `PUT /me` update, `filterUserUpdateInput` silently drops any attempt to modify `id`, `email`, `roles`, `lastLoginAt`, `signedUpAt`, `disabled`, `deletedAt`, and their `snake_case` equivalents. + +### Profile photo constraints + +- Accepted MIME types: `image/jpeg`, `image/png`, `image/webp` +- Default max size: 5 MB (override with `config.user.photoMaxSizeInMB`) +- Stored at `{userId}/photo` in `config.user.s3.bucket` + +### Custom handlers + +Any route handler can be replaced: + +```typescript +user: { + handlers: { + user: { + me: async (request, reply) => { /* custom me handler */ }, + users: async (request, reply) => { /* custom users list */ }, + }, + invitation: { + createInvitation: async (request, reply) => { /* custom create */ }, + }, + }, +} +``` + +--- + +## Invitation Routes + +| Method | Path | Auth | Description | +| -------- | --------------------------- | -------------------- | -------------------------- | +| `POST` | `/invitations` | `invitations:create` | Create and send invitation | +| `GET` | `/invitations` | `invitations:list` | Paginated invitation list | +| `GET` | `/invitations/token/:token` | public | Get invitation by token | +| `POST` | `/invitations/token/:token` | public | Accept invitation | +| `PUT` | `/invitations/revoke/:id` | `invitations:revoke` | Revoke invitation | +| `POST` | `/invitations/resend/:id` | `invitations:resend` | Resend invitation email | +| `DELETE` | `/invitations/:id` | `invitations:delete` | Delete invitation record | + +### Invitation configuration + +```typescript +user: { + invitation: { + expireAfterInDays: 14, // default: 30 + acceptLinkPath: "/join/:token", // default: "/signup/token/:token" + postAccept: async (request, invitation, user) => { + // called after a user successfully accepts an invitation + }, + }, +} +``` + +### Invitation utilities + +```typescript +import { + isInvitationValid, + computeInvitationExpiresAt, + sendInvitation, + getOrigin, +} from "@prefabs.tech/fastify-user"; + +// Check if an invitation can still be accepted +isInvitationValid(invitation); // → boolean + +// Compute expiry timestamp from config +computeInvitationExpiresAt(config); // uses expireAfterInDays +computeInvitationExpiresAt(config, "2026-06-01T00:00:00.000Z"); // explicit date + +// Send invitation email from custom code +await sendInvitation(fastify, invitation, "https://app.example.com"); + +// Extract origin from a full URL +getOrigin("https://app.example.com/path"); // → "https://app.example.com" +getOrigin("not-a-url"); // → "" +``` + +--- + +## Role Routes + +| Method | Path | Auth | Description | +| -------- | -------------------- | ------- | ------------------------------------ | +| `POST` | `/roles` | session | Create a role | +| `DELETE` | `/roles` | session | Delete a role | +| `GET` | `/roles` | session | List all roles with permissions | +| `GET` | `/roles/permissions` | session | Get permissions for a named role | +| `PUT` | `/roles/permissions` | session | Replace permissions for a named role | + +### Role utilities + +```typescript +import { + isRoleExists, + areRolesExist, + ROLE_ADMIN, + ROLE_SUPERADMIN, + ROLE_USER, +} from "@prefabs.tech/fastify-user"; + +await isRoleExists("EDITOR"); // → boolean +await areRolesExist(["EDITOR", "VIEWER"]); // → boolean (all must exist) +``` + +--- + +## Permission Routes + +| Method | Path | Auth | Description | +| ------ | -------------- | ------- | ------------------------------- | +| `GET` | `/permissions` | session | List all configured permissions | + +Register application-specific permissions so they appear in this endpoint: + +```typescript +user: { + permissions: ["posts:create", "posts:delete", "posts:publish"], +} +``` + +### Built-in permission constants + +```typescript +import { + PERMISSIONS_USERS_LIST, + PERMISSIONS_USERS_READ, + PERMISSIONS_USERS_DISABLE, + PERMISSIONS_USERS_ENABLE, + PERMISSIONS_INVITATIONS_CREATE, + PERMISSIONS_INVITATIONS_DELETE, + PERMISSIONS_INVITATIONS_LIST, + PERMISSIONS_INVITATIONS_RESEND, + PERMISSIONS_INVITATIONS_REVOKE, +} from "@prefabs.tech/fastify-user"; +``` + +--- + +## Email + +### Custom email subjects and templates + +```typescript +user: { + emailOverrides: { + invitation: { subject: "You're invited!", templateName: "my-invitation" }, + resetPassword: { subject: "Reset your password" }, + emailVerification: { templateName: "verify-email-custom" }, + }, +} +``` + +Overridable emails: `invitation`, `resetPassword`, `resetPasswordNotification`, `emailVerification`, `duplicateEmail`. + +### `sendEmail` utility + +Sends a templated email via `fastify.mailer`. `appName` from config is automatically merged into `templateData`: + +```typescript +import { sendEmail } from "@prefabs.tech/fastify-user"; + +await sendEmail({ + fastify, + subject: "Welcome!", + templateName: "welcome", + to: "user@example.com", + templateData: { firstName: "Alice" }, +}); +``` + +### `verifyEmail` utility + +Programmatically marks a user's email as verified (useful for invited users who can skip the verification link): + +```typescript +import { verifyEmail } from "@prefabs.tech/fastify-user"; + +await verifyEmail(userId, userEmail); +``` + +--- + +## Validation Utilities + +### `validateEmail(email, config)` + +Validates an email string against `config.user.email` options. Returns `{ success: true }` or `{ success: false, message }`: + +```typescript +import { validateEmail } from "@prefabs.tech/fastify-user"; + +const result = validateEmail("user@example.com", fastify.config); +if (!result.success) throw new Error(result.message); +``` + +### Email domain restrictions + +```typescript +user: { + email: { + host_whitelist: ["example.com"], // only allow these domains + host_blacklist: ["tempmail.com"], // block these domains + }, +} +``` + +### `validatePassword(password, config)` + +Validates password strength. Returns `{ success: true }` or `{ success: false, message }` with a human-readable description of failed requirements: + +```typescript +import { validatePassword } from "@prefabs.tech/fastify-user"; + +const result = validatePassword("MyP@ss1", fastify.config); +// { success: false, message: "Password should contain minimum 8 characters" } +``` + +### Password strength configuration + +```typescript +user: { + password: { + minLength: 10, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1, + }, +} +``` + +--- + +## Database Utilities + +### Migration queries + +Exported SQL factory functions for use if you need to run migrations manually or inspect the schema: + +```typescript +import { + createUsersTableQuery, + createInvitationsTableQuery, +} from "@prefabs.tech/fastify-user"; + +await db.query(createUsersTableQuery(config)); +await db.query(createInvitationsTableQuery(config)); +``` + +### Custom table names + +```typescript +user: { + tables: { + users: { name: "app_users" }, + invitations: { name: "app_invitations" }, + }, +} +``` + +### Service and SQL factory exports + +These classes are exported for direct use in custom service layers: + +- `UserService` — database operations for users +- `InvitationService` — database operations for invitations +- `RoleService` — SuperTokens role operations +- `UserSqlFactory` — SQL fragment builder for user queries +- `InvitationSqlFactory` — SQL fragment builder for invitation queries +- `createUserFilterFragment`, `createRoleSortFragment` — reusable Slonik SQL fragments + +--- + +## GraphQL Integration + +> Requires `config.graphql.enabled = true` and `@prefabs.tech/fastify-graphql` registered before this plugin. + +### Setup + +Merge the exported schema and resolvers into your Mercurius setup: + +```typescript +import { + userSchema, + userResolver, + invitationResolver, + roleResolver, + permissionResolver, +} from "@prefabs.tech/fastify-user"; +import { mergeTypeDefs } from "@graphql-tools/merge"; + +const typeDefs = mergeTypeDefs([userSchema, yourOtherSchema]); +const resolvers = { + ...userResolver, + ...invitationResolver, + ...roleResolver, + ...permissionResolver, +}; +``` + +### `@auth` directive + +Protects a GraphQL field or mutation. Checks: (1) valid session, (2) account not disabled, (3) email verified (if enabled), (4) profile complete (if enabled). + +```graphql +type Query { + dashboard: DashboardData @auth + profile: User @auth(emailVerification: false, profileValidation: false) +} +``` + +Pass `emailVerification: false` or `profileValidation: false` to skip those checks on a specific field. + +### `@hasPermission` directive + +Enforces a named permission on a GraphQL field. `SUPERADMIN` bypasses automatically: + +```graphql +type Mutation { + deletePost(id: ID!): Boolean @hasPermission(permission: "posts:delete") +} +``` + +### `MercuriusContext` augmentation + +`context.user` and `context.roles` are available in every resolver: + +```typescript +const resolvers = { + Query: { + myData: async (_parent, _args, context) => { + const { user, roles } = context; + // ... + }, + }, +}; +``` + +### Available GraphQL operations + +**User:** `canAdminSignUp`, `me`, `user(id)`, `users(limit, offset, filters, sort)` / `adminSignUp`, `changeEmail`, `changePassword`, `deleteMe`, `disableUser`, `enableUser`, `removePhoto`, `updateMe`, `uploadPhoto` + +**Invitation:** `getInvitationByToken`, `listInvitation` / `acceptInvitation`, `createInvitation`, `deleteInvitation`, `resendInvitation`, `revokeInvitation` + +**Role:** `roles`, `rolePermissions` / `createRole`, `deleteRole`, `updateRolePermissions` + +**Permission:** `permissions` + +--- + +## ApiConfig Extension + +This package extends `ApiConfig` (from `@prefabs.tech/fastify-config`) with a `user` field. TypeScript picks this up automatically via module augmentation — no extra setup needed: + +```typescript +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + user: UserConfig; // added by this package + } +} +``` + +--- + +## Type Exports + +| Type | Description | +| ------------------------------- | --------------------------------------------------- | +| `UserConfig` | Full plugin configuration shape | +| `SupertokensConfig` | SuperTokens sub-configuration | +| `User` | User database record | +| `AuthUser` | Combined SuperTokens + database user | +| `UserCreateInput` | Input for creating a user | +| `UserUpdateInput` | Input for updating a user | +| `Invitation` | Invitation database record | +| `InvitationCreateInput` | Input for creating an invitation | +| `InvitationUpdateInput` | Input for updating an invitation | +| `EmailOptions` | Email subject/template override shape | +| `StrongPasswordOptions` | Password strength configuration | +| `IsEmailOptions` | Email validation configuration | +| `SessionRecipe` | SuperTokens session recipe override type | +| `ThirdPartyEmailPasswordRecipe` | SuperTokens TPEP recipe override type | +| `EmailVerificationRecipe` | SuperTokens email verification recipe override type | + +--- + +## Use Cases + +### Protecting routes with session + permission + +When you need a route that requires both authentication and a specific permission: + +```typescript +fastify.delete( + "/articles/:id", + { + preHandler: [ + fastify.verifySession(), + fastify.hasPermission("articles:delete"), + ], + }, + async (request) => { + const userId = request.session!.getUserId(); + // delete the article... + }, +); +``` + +### Invitation-based onboarding flow + +When your app uses invitation-only sign-up, configure the acceptance path and add post-accept logic: + +```typescript +user: { + invitation: { + acceptLinkPath: "/onboarding/:token", + expireAfterInDays: 7, + postAccept: async (request, invitation, user) => { + // e.g. assign the user to the correct tenant + await assignUserToApp(invitation.appId, user.id); + }, + }, +} +``` + +### Enforcing profile completeness after sign-up + +When you need users to fill in required fields before accessing the app, use the profile validation claim with a grace period: + +```typescript +user: { + features: { + profileValidation: { + enabled: true, + fields: ["photo"], + gracePeriodInDays: 3, + }, + }, +} +``` + +`GET /me` and `PUT /me` bypass the claim automatically so users can always update their profile. After 3 days, any other session-protected route returns `403` until the photo is uploaded. + +### Overriding a single SuperTokens recipe function + +When you need to add custom logic to SuperTokens sign-up without replacing the entire recipe: + +```typescript +user: { + supertokens: { + recipes: { + thirdPartyEmailPassword: { + override: { + functions: (originalImpl, fastify) => ({ + emailPasswordSignUp: async (input) => { + const result = await originalImpl.emailPasswordSignUp(input); + if (result.status === "OK") { + await fastify.analytics.track("sign_up", { userId: result.user.id }); + } + return result; + }, + }), + }, + }, + }, + }, +} +``` + +### Disabling email verification on specific GraphQL fields + +When a mutation must work even before the user has verified their email: + +```graphql +type Mutation { + resendVerificationEmail: Boolean @auth(emailVerification: false) +} +``` diff --git a/packages/user/README.md b/packages/user/README.md index e3bc3e4dd..148644ee7 100644 --- a/packages/user/README.md +++ b/packages/user/README.md @@ -2,16 +2,29 @@ A [Fastify](https://github.com/fastify/fastify) plugin that provides an easy integration of user model (service, controller, resolver) in a fastify API. +## Why this plugin? + +User management—authentication, password hashing, multifactor sessions, session invalidation, and third-party SSO—is historically the most highly audited and volatile part of any backend system. We created this plugin to abstract that immense architectural complexity entirely by marrying SuperTokens directly into our monorepo toolset: + +- **Provide a Drop-In Authentication System**: Seamlessly hooks into `@prefabs.tech/fastify-slonik`, `@prefabs.tech/fastify-mailer`, and Fastify routers to rigorously manage passwords, sessions, and login states internally out of the box. +- **Instant GraphQL and REST Architectures**: Bootstraps massively scaffolded REST routes, GraphQL schemas (`userSchema`), and graph resolvers natively so you don't have to ever architect or rewrite complex authentication layers again. +- **Enforce Security By Default**: It leverages battle-tested frameworks to natively handle strong password requirements, seamless refresh token rotations, and edge-case CORS protections inherently invisible to developers. + +### Design Decisions: Why not custom JWTs, Passport.js, or Auth0? + +1. **Security Vulnerabilities vs Homemade Systems**: Maintaining a homegrown JWT authentication flow commonly leads to compromised token invalidation states, XSS exposures, or improper cryptographic recycling. Relying on an enterprise-grade framework prevents critical breaches natively. +2. **Why SuperTokens specifically**: We chose SuperTokens because it is fully open-source, architecturally flawless, and allows for extensive local overrides (e.g., custom OAuth, native password reset emails). Unlike heavy restrictive SaaS products (like Auth0 or Firebase Auth), using SuperTokens in combination with our own databases ensures you actually possess, own, and control your users' data natively without vender lock-ins. + ## Requirements -* [@fastify/cors](https://github.com/fastify/fastify-cors) -* [@fastify/formbody](https://github.com/fastify/fastify-formbody) -* [@prefabs.tech/fastify-config](../config/) -* [@prefabs.tech/fastify-mailer](../mailer/) -* [@prefabs.tech/fastify-s3](../s3/) -* [@prefabs.tech/fastify-slonik](../slonik/) -* [slonik](https://github.com/spa5k/fastify-slonik) -* [supertokens-node](https://github.com/supertokens/supertokens-node) +- [@fastify/cors](https://github.com/fastify/fastify-cors) +- [@fastify/formbody](https://github.com/fastify/fastify-formbody) +- [@prefabs.tech/fastify-config](../config/) +- [@prefabs.tech/fastify-mailer](../mailer/) +- [@prefabs.tech/fastify-s3](../s3/) +- [@prefabs.tech/fastify-slonik](../slonik/) +- [slonik](https://github.com/spa5k/fastify-slonik) +- [supertokens-node](https://github.com/supertokens/supertokens-node) ## Installation @@ -38,7 +51,9 @@ import configPlugin from "@prefabs.tech/fastify-config"; import mailerPlugin from "@prefabs.tech/fastify-mailer"; import s3Plugin, { multipartParserPlugin } from "@prefabs.tech/fastify-s3"; import slonikPlugin, { migrationPlugin } from "@prefabs.tech/fastify-slonik"; -import userPlugin, { SUPERTOKENS_CORS_HEADERS } from "@prefabs.tech/fastify-user"; +import userPlugin, { + SUPERTOKENS_CORS_HEADERS, +} from "@prefabs.tech/fastify-user"; import Fastify from "fastify"; import config from "./config"; @@ -57,10 +72,10 @@ const start = async () => { // Register cors plugin await fastify.register(corsPlugin, { - origin: config.appOrigin, allowedHeaders: ["Content-Type", ...SUPERTOKENS_CORS_HEADERS], - methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"], credentials: true, + methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"], + origin: config.appOrigin, }); // Register form-body plugin @@ -73,9 +88,9 @@ const start = async () => { await fastify.register(mailerPlugin, config.mailer); // Register multipart content-type parser plugin - await api.register(multipartParserPlugin); - - // Register mailer plugin + await fastify.register(multipartParserPlugin); + + // Register s3 plugin await fastify.register(s3Plugin); // Register fastify-user plugin @@ -83,10 +98,10 @@ const start = async () => { // Run app database migrations await fastify.register(migrationPlugin, config.slonik); - + await fastify.listen({ - port: config.port, host: "0.0.0.0", + port: config.port, }); }; @@ -94,27 +109,30 @@ start(); ``` ## Configuration + To add custom email and password validations: + ```typescript const config: ApiConfig = { // ... user: { //... email: { - host_whitelist: ["..."] + host_whitelist: ["..."], }, password: { minLength: 8, minLowercase: 1, - minUppercase: 0, minNumbers: 1, minSymbols: 0, - } - } + minUppercase: 0, + }, + }, }; ``` To overwrite ThirdPartyEmailPassword recipes from config: + ```typescript const config: ApiConfig = { // ... @@ -161,6 +179,7 @@ const config: ApiConfig = { }, }; ``` + **_NOTE:_** Each above overridden elements is a wrapper function. For example to override `emailPasswordSignUpPOST` see [emailPasswordSignUpPOST](src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts). ## Using GraphQL @@ -210,13 +229,13 @@ export default schema; To integrate the resolvers provided by this package, import them and merge with your application's resolvers: ```typescript -import { usersResolver } from "@prefabs.tech/fastify-user"; +import { userResolver } from "@prefabs.tech/fastify-user"; import type { IResolvers } from "mercurius"; const resolvers: IResolvers = { Mutation: { - ...usersResolver.Mutation, + ...userResolver.Mutation, }, Query: { ...userResolver.Query, diff --git a/packages/user/package.json b/packages/user/package.json index 73efc146b..665a85c39 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-user", - "version": "0.93.5", + "version": "0.94.0", "description": "Fastify user plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/user#readme", "repository": { @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-user.cjs", "module": "./dist/prefabs-tech-fastify-user.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -30,53 +32,53 @@ }, "dependencies": { "humps": "2.0.1", - "validator": "13.15.26" + "validator": "13.15.35" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/fastify-error-handler": "0.93.5", - "@prefabs.tech/fastify-graphql": "0.93.5", - "@prefabs.tech/fastify-mailer": "0.93.5", - "@prefabs.tech/fastify-s3": "0.93.5", - "@prefabs.tech/fastify-slonik": "0.93.5", - "@prefabs.tech/tsconfig": "0.5.0", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/fastify-error-handler": "0.94.0", + "@prefabs.tech/fastify-graphql": "0.94.0", + "@prefabs.tech/fastify-mailer": "0.94.0", + "@prefabs.tech/fastify-s3": "0.94.0", + "@prefabs.tech/fastify-slonik": "0.94.0", + "@prefabs.tech/tsconfig": "0.7.0", "@types/humps": "2.0.6", - "@types/node": "24.10.13", + "@types/node": "24.10.15", "@types/validator": "13.15.10", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "graphql": "16.12.0", - "mercurius": "16.7.0", + "graphql": "16.13.2", + "mercurius": "16.9.0", "mercurius-auth": "6.0.0", - "prettier": "3.8.1", + "prettier": "3.8.3", "slonik": "46.8.0", "supertokens-node": "14.1.4", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4", "zod": "3.25.76" }, "peerDependencies": { "@fastify/cors": ">=11.0.1", "@fastify/formbody": ">=8.0.2", - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/fastify-error-handler": "0.93.5", - "@prefabs.tech/fastify-graphql": "0.93.5", - "@prefabs.tech/fastify-mailer": "0.93.5", - "@prefabs.tech/fastify-s3": "0.93.5", - "@prefabs.tech/fastify-slonik": "0.93.5", - "fastify": ">=5.2.1", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/fastify-error-handler": "0.94.0", + "@prefabs.tech/fastify-graphql": "0.94.0", + "@prefabs.tech/fastify-mailer": "0.94.0", + "@prefabs.tech/fastify-s3": "0.94.0", + "@prefabs.tech/fastify-slonik": "0.94.0", + "fastify": ">=5.2.2", "fastify-plugin": ">=5.0.1", "mercurius": ">=16.1.0", "mercurius-auth": ">=6.0.0", "slonik": ">=46.1.0", - "supertokens-node": ">=14.1.3", + "supertokens-node": ">=14.1.4", "zod": ">=3.23.8" }, "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/user/src/__test__/constants.spec.ts b/packages/user/src/__test__/constants.spec.ts new file mode 100644 index 000000000..c2a13eaf1 --- /dev/null +++ b/packages/user/src/__test__/constants.spec.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB, + EMAIL_VERIFICATION_MODE, + EMAIL_VERIFICATION_PATH, + ERROR_CODES, + INVITATION_ACCEPT_LINK_PATH, + INVITATION_EXPIRE_AFTER_IN_DAYS, + PERMISSIONS_INVITATIONS_CREATE, + PERMISSIONS_INVITATIONS_DELETE, + PERMISSIONS_INVITATIONS_LIST, + PERMISSIONS_INVITATIONS_RESEND, + PERMISSIONS_INVITATIONS_REVOKE, + PERMISSIONS_USERS_DISABLE, + PERMISSIONS_USERS_ENABLE, + PERMISSIONS_USERS_LIST, + PERMISSIONS_USERS_READ, + RESET_PASSWORD_PATH, + ROLE_ADMIN, + ROLE_SUPERADMIN, + ROLE_USER, + ROUTE_CHANGE_EMAIL, + ROUTE_CHANGE_PASSWORD, + ROUTE_INVITATIONS, + ROUTE_INVITATIONS_ACCEPT, + ROUTE_INVITATIONS_GET_BY_TOKEN, + ROUTE_ME, + ROUTE_ME_PHOTO, + ROUTE_PERMISSIONS, + ROUTE_ROLES, + ROUTE_ROLES_PERMISSIONS, + ROUTE_SIGNUP_ADMIN, + ROUTE_USERS, + ROUTE_USERS_DISABLE, + ROUTE_USERS_ENABLE, + ROUTE_USERS_FIND_BY_ID, + SUPERTOKENS_CORS_HEADERS, + TABLE_INVITATIONS, + TABLE_USERS, +} from "../constants"; + +describe("role constants", () => { + it("ROLE_ADMIN is 'ADMIN'", () => { + expect(ROLE_ADMIN).toBe("ADMIN"); + }); + + it("ROLE_SUPERADMIN is 'SUPERADMIN'", () => { + expect(ROLE_SUPERADMIN).toBe("SUPERADMIN"); + }); + + it("ROLE_USER is 'USER'", () => { + expect(ROLE_USER).toBe("USER"); + }); +}); + +describe("permission constants", () => { + it.each([ + [PERMISSIONS_INVITATIONS_CREATE, "invitations:create"], + [PERMISSIONS_INVITATIONS_DELETE, "invitations:delete"], + [PERMISSIONS_INVITATIONS_LIST, "invitations:list"], + [PERMISSIONS_INVITATIONS_RESEND, "invitations:resend"], + [PERMISSIONS_INVITATIONS_REVOKE, "invitations:revoke"], + [PERMISSIONS_USERS_DISABLE, "users:disable"], + [PERMISSIONS_USERS_ENABLE, "users:enable"], + [PERMISSIONS_USERS_LIST, "users:list"], + [PERMISSIONS_USERS_READ, "users:read"], + ])("permission constant %s equals expected value", (constant, expected) => { + expect(constant).toBe(expected); + }); +}); + +describe("table name constants", () => { + it("TABLE_USERS is 'users'", () => { + expect(TABLE_USERS).toBe("users"); + }); + + it("TABLE_INVITATIONS is 'invitations'", () => { + expect(TABLE_INVITATIONS).toBe("invitations"); + }); +}); + +describe("route constants", () => { + it("ROUTE_ME is '/me'", () => { + expect(ROUTE_ME).toBe("/me"); + }); + + it("ROUTE_ME_PHOTO is '/me/photo'", () => { + expect(ROUTE_ME_PHOTO).toBe("/me/photo"); + }); + + it("ROUTE_USERS is '/users'", () => { + expect(ROUTE_USERS).toBe("/users"); + }); + + it("ROUTE_USERS_FIND_BY_ID contains :id", () => { + expect(ROUTE_USERS_FIND_BY_ID).toContain(":id"); + }); + + it("ROUTE_USERS_DISABLE ends with /disable", () => { + expect(ROUTE_USERS_DISABLE).toMatch(/\/disable$/); + }); + + it("ROUTE_USERS_ENABLE ends with /enable", () => { + expect(ROUTE_USERS_ENABLE).toMatch(/\/enable$/); + }); + + it("ROUTE_CHANGE_EMAIL is '/change-email'", () => { + expect(ROUTE_CHANGE_EMAIL).toBe("/change-email"); + }); + + it("ROUTE_CHANGE_PASSWORD is '/change_password'", () => { + expect(ROUTE_CHANGE_PASSWORD).toBe("/change_password"); + }); + + it("ROUTE_SIGNUP_ADMIN is '/signup/admin'", () => { + expect(ROUTE_SIGNUP_ADMIN).toBe("/signup/admin"); + }); + + it("ROUTE_INVITATIONS is '/invitations'", () => { + expect(ROUTE_INVITATIONS).toBe("/invitations"); + }); + + it("ROUTE_INVITATIONS_ACCEPT contains :token", () => { + expect(ROUTE_INVITATIONS_ACCEPT).toContain(":token"); + }); + + it("ROUTE_INVITATIONS_GET_BY_TOKEN contains :token", () => { + expect(ROUTE_INVITATIONS_GET_BY_TOKEN).toContain(":token"); + }); + + it("ROUTE_ROLES is '/roles'", () => { + expect(ROUTE_ROLES).toBe("/roles"); + }); + + it("ROUTE_ROLES_PERMISSIONS is '/roles/permissions'", () => { + expect(ROUTE_ROLES_PERMISSIONS).toBe("/roles/permissions"); + }); + + it("ROUTE_PERMISSIONS is '/permissions'", () => { + expect(ROUTE_PERMISSIONS).toBe("/permissions"); + }); + + it("RESET_PASSWORD_PATH is '/reset-password'", () => { + expect(RESET_PASSWORD_PATH).toBe("/reset-password"); + }); + + it("EMAIL_VERIFICATION_PATH is '/verify-email'", () => { + expect(EMAIL_VERIFICATION_PATH).toBe("/verify-email"); + }); +}); + +describe("invitation constants", () => { + it("INVITATION_ACCEPT_LINK_PATH contains :token placeholder", () => { + expect(INVITATION_ACCEPT_LINK_PATH).toContain(":token"); + }); + + it("INVITATION_EXPIRE_AFTER_IN_DAYS is 30", () => { + expect(INVITATION_EXPIRE_AFTER_IN_DAYS).toBe(30); + }); +}); + +describe("SUPERTOKENS_CORS_HEADERS", () => { + it("is an array", () => { + expect(Array.isArray(SUPERTOKENS_CORS_HEADERS)).toBe(true); + }); + + it.each([ + "anti-csrf", + "authorization", + "fdi-version", + "front-token", + "rid", + "st-access-token", + "st-auth-mode", + "st-refresh-token", + ])("includes '%s'", (header) => { + expect(SUPERTOKENS_CORS_HEADERS).toContain(header); + }); + + it("has exactly 8 headers", () => { + expect(SUPERTOKENS_CORS_HEADERS).toHaveLength(8); + }); +}); + +describe("ERROR_CODES", () => { + it.each([ + ["CHANGE_PASSWORD", "CHANGE_PASSWORD_ERROR"], + ["INVALID_EMAIL", "INVALID_EMAIL_ERROR"], + ["INVALID_PASSWORD", "INVALID_PASSWORD_ERROR"], + ["INVITATION_ALREADY_EXISTS", "INVITATION_ALREADY_EXISTS_ERROR"], + ["INVITATION_NOT_FOUND", "INVITATION_NOT_FOUND_ERROR"], + ["PHOTO_FILE_MISSING", "PHOTO_FILE_MISSING_ERROR"], + ["PHOTO_FILE_TOO_LARGE", "PHOTO_FILE_TOO_LARGE_ERROR"], + ["ROLE_ALREADY_EXISTS", "ROLE_ALREADY_EXISTS_ERROR"], + ["ROLE_IN_USE", "ROLE_IN_USE_ERROR"], + ["ROLE_NOT_FOUND", "ROLE_NOT_FOUND_ERROR"], + ["ROLE_NOT_SUPPORTED", "ROLE_NOT_SUPPORTED_ERROR"], + ["UNSUPPORTED_PHOTO_FILE_TYPE", "UNSUPPORTED_PHOTO_FILE_TYPE_ERROR"], + ["USER_ALREADY_EXISTS", "USER_ALREADY_EXISTS_ERROR"], + ["USER_NOT_FOUND", "USER_NOT_FOUND_ERROR"], + ] as const)("ERROR_CODES.%s is '%s'", (key, expected) => { + expect(ERROR_CODES[key]).toBe(expected); + }); +}); + +describe("photo constants", () => { + it("DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB is 5", () => { + expect(DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB).toBe(5); + }); +}); + +describe("email verification constants", () => { + it("EMAIL_VERIFICATION_MODE is 'REQUIRED'", () => { + expect(EMAIL_VERIFICATION_MODE).toBe("REQUIRED"); + }); +}); diff --git a/packages/user/src/__test__/plugin.test.ts b/packages/user/src/__test__/plugin.test.ts new file mode 100644 index 000000000..380f6ba7e --- /dev/null +++ b/packages/user/src/__test__/plugin.test.ts @@ -0,0 +1,361 @@ +import type { FastifyInstance } from "fastify"; + +import Fastify from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ROUTE_INVITATIONS, + ROUTE_ME, + ROUTE_PERMISSIONS, + ROUTE_ROLES, + ROUTE_USERS, +} from "../constants"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockRunMigrations = vi.fn().mockResolvedValue(); +const mockSeedRoles = vi.fn().mockResolvedValue(); +const mockMercuriusAuthPlugin = vi.fn().mockResolvedValue(); + +vi.mock("../migrations/runMigrations", () => ({ default: mockRunMigrations })); +vi.mock("../lib/seedRoles", () => ({ default: mockSeedRoles })); +vi.mock("../mercurius-auth/plugin", () => ({ + default: mockMercuriusAuthPlugin, +})); + +// Mock the supertokens plugin as a noop so it doesn't try to connect to a +// real SuperTokens server. verifySession is pre-decorated in buildFastify below. +vi.mock("../supertokens", () => ({ + default: async () => {}, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// The route schemas reference "ErrorResponse#" which is registered by +// @prefabs.tech/fastify-error-handler in production. Register it manually here. +const errorResponseSchema = { + $id: "ErrorResponse", + additionalProperties: true, + properties: { + code: { type: "string" }, + error: { type: "string" }, + message: { type: "string" }, + statusCode: { type: "number" }, + }, + type: "object", +}; + +const buildFastify = ( + userConfig: Record = {}, + rootConfig: Record = {}, + slonik: Record = {}, +) => { + // Disable AJV strict mode so that custom keywords registered by + // peer plugins (e.g. `isFile` from @fastify/multipart) do not + // cause schema-compilation errors in the test environment. + const fastify = Fastify({ + ajv: { customOptions: { strict: false } }, + logger: false, + }); + fastify.addSchema(errorResponseSchema); + + fastify.decorate("config", { + appName: "TestApp", + appOrigin: ["http://localhost"], + baseUrl: "http://localhost", + ...rootConfig, + user: { + supertokens: { connectionUri: "http://localhost:3567" }, + ...userConfig, + }, + }); + fastify.decorate("slonik", slonik); + + // verifySession is normally added by the supertokens plugin. Since that plugin + // is mocked as a noop (to avoid real network calls), we add it here instead. + // It must return a function (a preHandler) when called with any options. + fastify.decorate( + "verifySession", + vi.fn().mockReturnValue(async () => {}), + ); + + fastify.decorate("httpErrors", { + forbidden: (message: string) => + Object.assign(new Error(message), { statusCode: 403 }), + notFound: (message: string) => + Object.assign(new Error(message), { statusCode: 404 }), + unauthorized: (message: string) => + Object.assign(new Error(message), { statusCode: 401 }), + }); + + return fastify; +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("userPlugin — decorators", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("decorates the instance with hasPermission", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasPermission).toBeDefined(); + expect(typeof fastify.hasPermission).toBe("function"); + await fastify.close(); + }); + + it("calls runMigrations during registration", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockRunMigrations).toHaveBeenCalledOnce(); + await fastify.close(); + }); + + it("calls seedRoles on ready", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockSeedRoles).toHaveBeenCalledOnce(); + await fastify.close(); + }); +}); + +describe("userPlugin — invitations routes", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("registers GET /invitations by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_INVITATIONS })).toBe( + true, + ); + await fastify.close(); + }); + + it("skips invitations routes when routes.invitations.disabled === true", async () => { + fastify = buildFastify({ routes: { invitations: { disabled: true } } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_INVITATIONS })).toBe( + false, + ); + await fastify.close(); + }); +}); + +describe("userPlugin — permissions routes", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("registers GET /permissions by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_PERMISSIONS })).toBe( + true, + ); + await fastify.close(); + }); + + it("skips permissions routes when routes.permissions.disabled === true", async () => { + fastify = buildFastify({ routes: { permissions: { disabled: true } } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_PERMISSIONS })).toBe( + false, + ); + await fastify.close(); + }); +}); + +describe("userPlugin — roles routes", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("registers GET /roles by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_ROLES })).toBe(true); + await fastify.close(); + }); + + it("skips roles routes when routes.roles.disabled === true", async () => { + fastify = buildFastify({ routes: { roles: { disabled: true } } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_ROLES })).toBe(false); + await fastify.close(); + }); +}); + +describe("userPlugin — users routes", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("registers GET /users by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_USERS })).toBe(true); + await fastify.close(); + }); + + it("registers GET /me by default", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_ME })).toBe(true); + await fastify.close(); + }); + + it("skips users routes when routes.users.disabled === true", async () => { + fastify = buildFastify({ routes: { users: { disabled: true } } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(fastify.hasRoute({ method: "GET", url: ROUTE_USERS })).toBe(false); + await fastify.close(); + }); +}); + +describe("userPlugin — routePrefix", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("mounts routes under the configured routePrefix", async () => { + fastify = buildFastify({ routePrefix: "/api/v1" }); + await fastify.register(plugin); + await fastify.ready(); + + expect( + fastify.hasRoute({ method: "GET", url: `/api/v1${ROUTE_USERS}` }), + ).toBe(true); + expect(fastify.hasRoute({ method: "GET", url: `/api/v1${ROUTE_ME}` })).toBe( + true, + ); + await fastify.close(); + }); + + it("mounts routes without a prefix when routePrefix is not set", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + // Routes should exist at the root path (no prefix) + expect(fastify.hasRoute({ method: "GET", url: ROUTE_USERS })).toBe(true); + await fastify.close(); + }); +}); + +describe("userPlugin — seedRoles receives user config", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("passes the user config to seedRoles", async () => { + const customRoles = ["MODERATOR", "EDITOR"]; + fastify = buildFastify({ roles: customRoles }); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockSeedRoles).toHaveBeenCalledWith( + expect.objectContaining({ roles: customRoles }), + ); + await fastify.close(); + }); +}); + +describe("userPlugin — runMigrations wiring", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("passes fastify config and slonik to runMigrations", async () => { + const slonik = { pool: "test-pool" }; + fastify = buildFastify({}, {}, slonik); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockRunMigrations).toHaveBeenCalledWith(fastify.config, slonik); + await fastify.close(); + }); +}); + +describe("userPlugin — GraphQL mercurius-auth wiring", async () => { + const { default: plugin } = await import("../plugin"); + let fastify: FastifyInstance; + + beforeEach(() => vi.clearAllMocks()); + + it("does not register mercurius-auth integration when graphql is omitted from config", async () => { + fastify = buildFastify(); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockMercuriusAuthPlugin).not.toHaveBeenCalled(); + await fastify.close(); + }); + + it("does not register mercurius-auth integration when graphql.enabled is false", async () => { + fastify = buildFastify({}, { graphql: { enabled: false } }); + await fastify.register(plugin); + await fastify.ready(); + + expect(mockMercuriusAuthPlugin).not.toHaveBeenCalled(); + await fastify.close(); + }); + + it("registers mercurius-auth integration when graphql.enabled is true", async () => { + fastify = buildFastify({}, { graphql: { enabled: true } }); + await fastify.register(plugin); + await fastify.ready(); + + // Avoid matchers that traverse the Fastify instance in mock call records + // (e.g. toHaveBeenCalledOnce, toEqual on args containing fastify) — they can + // trip Fastify's listeningOrigin getter before listen(). + expect(mockMercuriusAuthPlugin.mock.calls.length).toBe(1); + expect(mockMercuriusAuthPlugin.mock.calls[0]?.[2]).toBeTypeOf("function"); + + await fastify.close(); + }); +}); + +describe("userPlugin — default export", async () => { + const { default: plugin } = await import("../plugin"); + + it("exposes updateContext for Mercurius context wiring", () => { + expect(plugin.updateContext).toBeDefined(); + expect(typeof plugin.updateContext).toBe("function"); + }); +}); diff --git a/packages/user/src/constants.ts b/packages/user/src/constants.ts index 1b1e32691..8ad70f6d2 100644 --- a/packages/user/src/constants.ts +++ b/packages/user/src/constants.ts @@ -60,9 +60,9 @@ const ERROR_CODES = { PHOTO_FILE_MISSING: "PHOTO_FILE_MISSING_ERROR", PHOTO_FILE_TOO_LARGE: "PHOTO_FILE_TOO_LARGE_ERROR", ROLE_ALREADY_EXISTS: "ROLE_ALREADY_EXISTS_ERROR", + ROLE_IN_USE: "ROLE_IN_USE_ERROR", ROLE_NOT_FOUND: "ROLE_NOT_FOUND_ERROR", ROLE_NOT_SUPPORTED: "ROLE_NOT_SUPPORTED_ERROR", - ROLE_IN_USE: "ROLE_IN_USE_ERROR", UNKNOWN_ROLE_ERROR: "UNKNOWN_ROLE_ERROR", UNSUPPORTED_PHOTO_FILE_TYPE: "UNSUPPORTED_PHOTO_FILE_TYPE_ERROR", USER_ALREADY_EXISTS: "USER_ALREADY_EXISTS_ERROR", @@ -87,6 +87,7 @@ export { ERROR_CODES, INVITATION_ACCEPT_LINK_PATH, INVITATION_EXPIRE_AFTER_IN_DAYS, + PERMISSIONS_INVITATIONS_CREATE, PERMISSIONS_INVITATIONS_DELETE, PERMISSIONS_INVITATIONS_LIST, PERMISSIONS_INVITATIONS_RESEND, @@ -112,13 +113,12 @@ export { ROUTE_ME_PHOTO, ROUTE_PERMISSIONS, ROUTE_ROLES, - ROUTE_USERS_FIND_BY_ID, - PERMISSIONS_INVITATIONS_CREATE, ROUTE_ROLES_PERMISSIONS, ROUTE_SIGNUP_ADMIN, ROUTE_USERS, ROUTE_USERS_DISABLE, ROUTE_USERS_ENABLE, + ROUTE_USERS_FIND_BY_ID, SUPERTOKENS_CORS_HEADERS, TABLE_INVITATIONS, TABLE_USERS, diff --git a/packages/user/src/graphql/schema.ts b/packages/user/src/graphql/schema.ts index fb5f3ebf5..ff2848c76 100644 --- a/packages/user/src/graphql/schema.ts +++ b/packages/user/src/graphql/schema.ts @@ -1,4 +1,4 @@ -import { mergeTypeDefs, baseSchema } from "@prefabs.tech/fastify-graphql"; +import { baseSchema, mergeTypeDefs } from "@prefabs.tech/fastify-graphql"; import invitationSchema from "../model/invitations/graphql/schema"; import roleSchema from "../model/roles/graphql/schema"; diff --git a/packages/user/src/index.ts b/packages/user/src/index.ts index 592b5758b..4b7a96248 100644 --- a/packages/user/src/index.ts +++ b/packages/user/src/index.ts @@ -1,7 +1,7 @@ -import hasPermission from "./middlewares/hasPermission"; - import type { User, UserConfig } from "./types"; +import hasPermission from "./middlewares/hasPermission"; + declare module "fastify" { interface FastifyInstance { hasPermission: typeof hasPermission; @@ -15,7 +15,7 @@ declare module "fastify" { declare module "mercurius" { interface MercuriusContext { roles: string[] | undefined; - user: User | undefined; + user: undefined | User; } } @@ -25,48 +25,48 @@ declare module "@prefabs.tech/fastify-config" { } } -export { default } from "./plugin"; +export * from "./constants"; -export { default as userResolver } from "./model/users/graphql/resolver"; -export { default as UserSqlFactory } from "./model/users/sqlFactory"; -export { default as UserService } from "./model/users/service"; -export { default as getUserService } from "./lib/getUserService"; -export { default as userRoutes } from "./model/users/controller"; -export { default as invitationResolver } from "./model/invitations/graphql/resolver"; -export { default as InvitationSqlFactory } from "./model/invitations/sqlFactory"; -export { default as InvitationService } from "./model/invitations/service"; -export { default as getInvitationService } from "./lib/getInvitationService"; -export { default as invitationRoutes } from "./model/invitations/controller"; -export { default as permissionResolver } from "./model/permissions/resolver"; -export { default as permissionRoutes } from "./model/permissions/controller"; -export { default as RoleService } from "./model/roles/service"; -export { default as roleResolver } from "./model/roles/graphql/resolver"; -export { default as roleRoutes } from "./model/roles/controller"; -// [DU 2023-AUG-07] use formatDate from "@prefabs.tech/fastify-slonik" package -export { formatDate } from "@prefabs.tech/fastify-slonik"; +export { default as userSchema } from "./graphql/schema"; export { default as computeInvitationExpiresAt } from "./lib/computeInvitationExpiresAt"; +export { default as getInvitationService } from "./lib/getInvitationService"; export { default as getOrigin } from "./lib/getOrigin"; +export { default as getUserService } from "./lib/getUserService"; +export { default as hasUserPermission } from "./lib/hasUserPermission"; export { default as isInvitationValid } from "./lib/isInvitationValid"; export { default as sendEmail } from "./lib/sendEmail"; export { default as sendInvitation } from "./lib/sendInvitation"; export { default as verifyEmail } from "./lib/verifyEmail"; -export { default as isRoleExists } from "./supertokens/utils/isRoleExists"; +export * from "./migrations/queries"; +export { default as invitationRoutes } from "./model/invitations/controller"; +export { default as invitationResolver } from "./model/invitations/graphql/resolver"; +export { default as InvitationService } from "./model/invitations/service"; +export { default as InvitationSqlFactory } from "./model/invitations/sqlFactory"; +export { default as permissionRoutes } from "./model/permissions/controller"; +export { default as permissionResolver } from "./model/permissions/resolver"; +export { default as roleRoutes } from "./model/roles/controller"; +export { default as roleResolver } from "./model/roles/graphql/resolver"; +export { default as RoleService } from "./model/roles/service"; +export { default as userRoutes } from "./model/users/controller"; +export { default as userResolver } from "./model/users/graphql/resolver"; +export { default as UserService } from "./model/users/service"; +export { + createRoleSortFragment, + createUserFilterFragment, +} from "./model/users/sql"; +export { default as UserSqlFactory } from "./model/users/sqlFactory"; +export { default } from "./plugin"; +export { errorHandler as supertokensErrorHandler } from "./supertokens/errorHandler"; export { default as areRolesExist } from "./supertokens/utils/areRolesExist"; -export { default as validateEmail } from "./validator/email"; -export { default as validatePassword } from "./validator/password"; -export { default as hasUserPermission } from "./lib/hasUserPermission"; -export { default as ProfileValidationClaim } from "./supertokens/utils/profileValidationClaim"; export { default as createUserContext } from "./supertokens/utils/createUserContext"; -export { default as userSchema } from "./graphql/schema"; -export { errorHandler as supertokensErrorHandler } from "./supertokens/errorHandler"; +export { default as isRoleExists } from "./supertokens/utils/isRoleExists"; +export { default as ProfileValidationClaim } from "./supertokens/utils/profileValidationClaim"; -export * from "./migrations/queries"; +export type * from "./types"; -export * from "./constants"; +export { default as validateEmail } from "./validator/email"; -export type * from "./types"; +export { default as validatePassword } from "./validator/password"; -export { - createRoleSortFragment, - createUserFilterFragment, -} from "./model/users/sql"; +// [DU 2023-AUG-07] use formatDate from "@prefabs.tech/fastify-slonik" package +export { formatDate } from "@prefabs.tech/fastify-slonik"; diff --git a/packages/user/src/lib/__test__/computeInvitationExpiresAt.spec.ts b/packages/user/src/lib/__test__/computeInvitationExpiresAt.spec.ts new file mode 100644 index 000000000..1eecea499 --- /dev/null +++ b/packages/user/src/lib/__test__/computeInvitationExpiresAt.spec.ts @@ -0,0 +1,79 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { INVITATION_EXPIRE_AFTER_IN_DAYS } from "../../constants"; +import computeInvitationExpiresAt from "../computeInvitationExpiresAt"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +const FIXED_NOW = new Date("2024-06-15T12:00:00.000Z").getTime(); + +const baseConfig = { + user: {}, +} as unknown as ApiConfig; + +describe("computeInvitationExpiresAt", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the provided expireTime as-is when supplied", () => { + const expireTime = "2099-12-31 23:59:59.000"; + + expect(computeInvitationExpiresAt(baseConfig, expireTime)).toBe(expireTime); + }); + + it("returns a formatted date string when expireTime is not provided", () => { + const result = computeInvitationExpiresAt(baseConfig); + + // Format: "YYYY-MM-DD HH:mm:ss.mmm" + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/); + }); + + it(`uses the default expiry of ${INVITATION_EXPIRE_AFTER_IN_DAYS} days when not configured`, () => { + const result = computeInvitationExpiresAt(baseConfig); + + const expectedDate = new Date( + FIXED_NOW + INVITATION_EXPIRE_AFTER_IN_DAYS * MS_PER_DAY, + ); + const expectedString = expectedDate + .toISOString() + .slice(0, 23) + .replace("T", " "); + + expect(result).toBe(expectedString); + }); + + it("uses expireAfterInDays from config when provided", () => { + const config = { + user: { invitation: { expireAfterInDays: 7 } }, + } as unknown as ApiConfig; + + const result = computeInvitationExpiresAt(config); + + const expectedDate = new Date(FIXED_NOW + 7 * MS_PER_DAY); + const expectedString = expectedDate + .toISOString() + .slice(0, 23) + .replace("T", " "); + + expect(result).toBe(expectedString); + }); + + it("expiry date is later than current time", () => { + const result = computeInvitationExpiresAt(baseConfig); + + const now = new Date(FIXED_NOW) + .toISOString() + .slice(0, 23) + .replace("T", " "); + + expect(result > now).toBe(true); + }); +}); diff --git a/packages/user/src/lib/__test__/getInvitationLink.spec.ts b/packages/user/src/lib/__test__/getInvitationLink.spec.ts new file mode 100644 index 000000000..cbb336615 --- /dev/null +++ b/packages/user/src/lib/__test__/getInvitationLink.spec.ts @@ -0,0 +1,121 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { describe, expect, it } from "vitest"; + +import type { Invitation } from "../../types/invitation"; + +import { INVITATION_ACCEPT_LINK_PATH } from "../../constants"; +import getInvitationLink from "../getInvitationLink"; + +const baseInvitation: Invitation = { + createdAt: Date.now(), + email: "user@example.com", + expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 30, + id: 1, + invitedById: "inviter-id", + role: "USER", + token: "test-uuid-token", + updatedAt: Date.now(), +}; + +const baseConfig = { + user: {}, +} as unknown as ApiConfig; + +describe("getInvitationLink", () => { + it("uses the default accept link path when not configured", () => { + const link = getInvitationLink( + baseConfig, + baseInvitation, + "https://app.example.com", + ); + + expect(link).toBe( + `https://app.example.com/signup/token/${baseInvitation.token}`, + ); + }); + + it("substitutes the invitation token into the default path", () => { + const invitation: Invitation = { + ...baseInvitation, + token: "my-special-token", + }; + + const link = getInvitationLink( + baseConfig, + invitation, + "https://app.example.com", + ); + + expect(link).toContain("my-special-token"); + expect(link).not.toContain(":token"); + }); + + it("uses custom acceptLinkPath from config", () => { + const config = { + user: { + invitation: { acceptLinkPath: "/onboarding/accept/:token" }, + }, + } as unknown as ApiConfig; + + const link = getInvitationLink( + config, + baseInvitation, + "https://app.example.com", + ); + + expect(link).toBe( + `https://app.example.com/onboarding/accept/${baseInvitation.token}`, + ); + }); + + it("preserves origin correctly in the returned URL", () => { + const link = getInvitationLink( + baseConfig, + baseInvitation, + "https://staging.myapp.io", + ); + + expect(link.startsWith("https://staging.myapp.io/")).toBe(true); + }); + + it("replaces all occurrences of :token in the path", () => { + const config = { + user: { + invitation: { acceptLinkPath: "/a/:token/verify/:token" }, + }, + } as unknown as ApiConfig; + + const link = getInvitationLink( + config, + baseInvitation, + "https://app.example.com", + ); + + expect(link).toBe( + `https://app.example.com/a/${baseInvitation.token}/verify/${baseInvitation.token}`, + ); + }); + + it("does not replace :token when followed by a word character", () => { + const config = { + user: { + invitation: { acceptLinkPath: "/invite/:tokenizer" }, + }, + } as unknown as ApiConfig; + + const link = getInvitationLink( + config, + baseInvitation, + "https://app.example.com", + ); + + // :tokenizer should NOT be replaced + expect(link).toContain(":tokenizer"); + expect(link).not.toContain(baseInvitation.token); + }); + + it("default INVITATION_ACCEPT_LINK_PATH constant contains :token placeholder", () => { + expect(INVITATION_ACCEPT_LINK_PATH).toContain(":token"); + }); +}); diff --git a/packages/user/src/lib/__test__/hasUserPermission.spec.ts b/packages/user/src/lib/__test__/hasUserPermission.spec.ts new file mode 100644 index 000000000..9c78492b4 --- /dev/null +++ b/packages/user/src/lib/__test__/hasUserPermission.spec.ts @@ -0,0 +1,142 @@ +import type { FastifyInstance } from "fastify"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROLE_SUPERADMIN } from "../../constants"; +import hasUserPermission from "../hasUserPermission"; + +// Mock supertokens UserRoles so we can control what roles/permissions come back +// without needing a running SuperTokens server. +vi.mock("supertokens-node/recipe/userroles", () => ({ + default: { + getPermissionsForRole: vi.fn(), + getRolesForUser: vi.fn(), + }, +})); + +import UserRoles from "supertokens-node/recipe/userroles"; + +const mockGetRolesForUser = vi.mocked(UserRoles.getRolesForUser); +const mockGetPermissionsForRole = vi.mocked(UserRoles.getPermissionsForRole); + +const makeFastify = (permissions?: string[]) => + ({ config: { user: { permissions } } }) as unknown as FastifyInstance; + +describe("hasUserPermission", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("short-circuit: permission not registered in config", () => { + it("returns true when config.user.permissions is undefined", async () => { + const result = await hasUserPermission( + makeFastify(), + "user-1", + "reports:export", + ); + + expect(result).toBe(true); + expect(mockGetRolesForUser).not.toHaveBeenCalled(); + }); + + it("returns true when config.user.permissions is an empty array", async () => { + const result = await hasUserPermission( + makeFastify([]), + "user-1", + "reports:export", + ); + + expect(result).toBe(true); + expect(mockGetRolesForUser).not.toHaveBeenCalled(); + }); + + it("returns true when the requested permission is not in the configured list", async () => { + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "user-1", + "reports:export", // not in the list + ); + + expect(result).toBe(true); + expect(mockGetRolesForUser).not.toHaveBeenCalled(); + }); + }); + + describe("SUPERADMIN bypass", () => { + it("returns true for a SUPERADMIN regardless of the requested permission", async () => { + mockGetRolesForUser.mockResolvedValue({ + roles: [ROLE_SUPERADMIN], + status: "OK", + }); + + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "superadmin-1", + "billing:manage", + ); + + expect(result).toBe(true); + expect(mockGetPermissionsForRole).not.toHaveBeenCalled(); + }); + }); + + describe("permission check via role", () => { + it("returns true when the user holds a role that grants the required permission", async () => { + mockGetRolesForUser.mockResolvedValue({ + roles: ["EDITOR"], + status: "OK", + }); + mockGetPermissionsForRole.mockResolvedValue({ + permissions: ["content:publish", "billing:manage"], + status: "OK", + }); + + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "user-1", + "billing:manage", + ); + + expect(result).toBe(true); + }); + + it("returns false when none of the user's roles grant the required permission", async () => { + mockGetRolesForUser.mockResolvedValue({ + roles: ["VIEWER"], + status: "OK", + }); + mockGetPermissionsForRole.mockResolvedValue({ + permissions: ["content:read"], + status: "OK", + }); + + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "user-1", + "billing:manage", + ); + + expect(result).toBe(false); + }); + + it("de-duplicates permissions when multiple roles grant the same permission", async () => { + mockGetRolesForUser.mockResolvedValue({ + roles: ["ROLE_A", "ROLE_B"], + status: "OK", + }); + // Both roles grant the same permission + mockGetPermissionsForRole.mockResolvedValue({ + permissions: ["billing:manage"], + status: "OK", + }); + + const result = await hasUserPermission( + makeFastify(["billing:manage"]), + "user-1", + "billing:manage", + ); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/user/src/lib/__test__/isInvitationValid.spec.ts b/packages/user/src/lib/__test__/isInvitationValid.spec.ts new file mode 100644 index 000000000..cfedb86e0 --- /dev/null +++ b/packages/user/src/lib/__test__/isInvitationValid.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import type { Invitation } from "../../types/invitation"; + +import isInvitationValid from "../isInvitationValid"; + +const baseInvitation: Invitation = { + createdAt: Date.now(), + email: "user@example.com", + expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 30, // 30 days from now + id: 1, + invitedById: "inviter-id", + role: "USER", + token: "abc-token-uuid", + updatedAt: Date.now(), +}; + +describe("isInvitationValid", () => { + it("returns true for a pending, non-expired invitation", () => { + expect(isInvitationValid(baseInvitation)).toBe(true); + }); + + it("returns false when invitation has been accepted", () => { + const invitation: Invitation = { + ...baseInvitation, + acceptedAt: Date.now() - 1000, + }; + + expect(isInvitationValid(invitation)).toBe(false); + }); + + it("returns false when invitation has been revoked", () => { + const invitation: Invitation = { + ...baseInvitation, + revokedAt: Date.now() - 1000, + }; + + expect(isInvitationValid(invitation)).toBe(false); + }); + + it("returns false when invitation has expired", () => { + const invitation: Invitation = { + ...baseInvitation, + expiresAt: Date.now() - 1, + }; + + expect(isInvitationValid(invitation)).toBe(false); + }); + + it("returns false when invitation is accepted, revoked, and expired simultaneously", () => { + const past = Date.now() - 1000; + const invitation: Invitation = { + ...baseInvitation, + acceptedAt: past, + expiresAt: past, + revokedAt: past, + }; + + expect(isInvitationValid(invitation)).toBe(false); + }); + + it("returns true when expiry is exactly in the future", () => { + const invitation: Invitation = { + ...baseInvitation, + expiresAt: Date.now() + 1000, + }; + + expect(isInvitationValid(invitation)).toBe(true); + }); +}); diff --git a/packages/user/src/lib/__test__/seedRoles.spec.ts b/packages/user/src/lib/__test__/seedRoles.spec.ts new file mode 100644 index 000000000..1f33426c9 --- /dev/null +++ b/packages/user/src/lib/__test__/seedRoles.spec.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ROLE_ADMIN, ROLE_SUPERADMIN, ROLE_USER } from "../../constants"; +import seedRoles from "../seedRoles"; + +vi.mock("supertokens-node/recipe/userroles", () => ({ + default: { + createNewRoleOrAddPermissions: vi.fn().mockResolvedValue({ status: "OK" }), + }, +})); + +import UserRoles from "supertokens-node/recipe/userroles"; + +const mockCreate = vi.mocked(UserRoles.createNewRoleOrAddPermissions); + +describe("seedRoles", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("seeds ADMIN, SUPERADMIN, and USER by default", async () => { + await seedRoles(); + + const seededRoles = mockCreate.mock.calls.map(([role]) => role); + + expect(seededRoles).toContain(ROLE_ADMIN); + expect(seededRoles).toContain(ROLE_SUPERADMIN); + expect(seededRoles).toContain(ROLE_USER); + }); + + it("seeds exactly the three default roles when no extras are configured", async () => { + await seedRoles(); + + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("seeds custom roles in addition to the three defaults", async () => { + await seedRoles({ roles: ["MODERATOR", "EDITOR"] }); + + const seededRoles = mockCreate.mock.calls.map(([role]) => role); + + expect(seededRoles).toContain("MODERATOR"); + expect(seededRoles).toContain("EDITOR"); + expect(seededRoles).toHaveLength(5); // 3 defaults + 2 custom + }); + + it("seeds only the three defaults when config.roles is an empty array", async () => { + await seedRoles({ roles: [] }); + + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("seeds only the three defaults when userConfig is undefined", async () => { + await seedRoles(); + + expect(mockCreate).toHaveBeenCalledTimes(3); + }); + + it("creates each role with an empty permissions array", async () => { + await seedRoles(); + + for (const [, permissions] of mockCreate.mock.calls) { + expect(permissions).toEqual([]); + } + }); +}); diff --git a/packages/user/src/lib/computeInvitationExpiresAt.ts b/packages/user/src/lib/computeInvitationExpiresAt.ts index bfabd557c..635a5af68 100644 --- a/packages/user/src/lib/computeInvitationExpiresAt.ts +++ b/packages/user/src/lib/computeInvitationExpiresAt.ts @@ -1,9 +1,9 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + import { formatDate } from "@prefabs.tech/fastify-slonik"; import { INVITATION_EXPIRE_AFTER_IN_DAYS } from "../constants"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; - const computeInvitationExpiresAt = (config: ApiConfig, expireTime?: string) => { return ( expireTime || diff --git a/packages/user/src/lib/getInvitationLink.ts b/packages/user/src/lib/getInvitationLink.ts index 1a644483e..beeb9ce7d 100644 --- a/packages/user/src/lib/getInvitationLink.ts +++ b/packages/user/src/lib/getInvitationLink.ts @@ -1,7 +1,8 @@ -import { INVITATION_ACCEPT_LINK_PATH } from "../constants"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Invitation } from "../types/invitation"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import { INVITATION_ACCEPT_LINK_PATH } from "../constants"; const getInvitationLink = ( config: ApiConfig, diff --git a/packages/user/src/lib/getInvitationService.ts b/packages/user/src/lib/getInvitationService.ts index 89c649c61..d33906907 100644 --- a/packages/user/src/lib/getInvitationService.ts +++ b/packages/user/src/lib/getInvitationService.ts @@ -1,8 +1,8 @@ -import InvitationService from "../model/invitations/service"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import InvitationService from "../model/invitations/service"; + const getInvitationService = ( config: ApiConfig, slonik: Database, diff --git a/packages/user/src/lib/getUserService.ts b/packages/user/src/lib/getUserService.ts index 6ae6319bb..4437a63f7 100644 --- a/packages/user/src/lib/getUserService.ts +++ b/packages/user/src/lib/getUserService.ts @@ -1,8 +1,8 @@ -import UserService from "../model/users/service"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import UserService from "../model/users/service"; + const getUserService = ( config: ApiConfig, slonik: Database, diff --git a/packages/user/src/lib/hasUserPermission.ts b/packages/user/src/lib/hasUserPermission.ts index 46be56221..5c507ae35 100644 --- a/packages/user/src/lib/hasUserPermission.ts +++ b/packages/user/src/lib/hasUserPermission.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance } from "fastify"; + import UserRoles from "supertokens-node/recipe/userroles"; import { ROLE_SUPERADMIN } from "../constants"; -import type { FastifyInstance } from "fastify"; - const getPermissions = async (roles: string[]) => { let permissions: string[] = []; diff --git a/packages/user/src/lib/seedRoles.ts b/packages/user/src/lib/seedRoles.ts index 5ca23899e..ab53de021 100644 --- a/packages/user/src/lib/seedRoles.ts +++ b/packages/user/src/lib/seedRoles.ts @@ -3,7 +3,7 @@ import UserRoles from "supertokens-node/recipe/userroles"; import { ROLE_ADMIN, ROLE_SUPERADMIN, ROLE_USER } from "../constants"; import { UserConfig } from "../types"; -const seedRoles = async (userConfig?: UserConfig) => { +const seedRoles = async (userConfig?: Partial) => { const roles = [ ROLE_ADMIN, ROLE_SUPERADMIN, diff --git a/packages/user/src/lib/sendEmail.ts b/packages/user/src/lib/sendEmail.ts index 381559c14..2b1a9e283 100644 --- a/packages/user/src/lib/sendEmail.ts +++ b/packages/user/src/lib/sendEmail.ts @@ -20,12 +20,12 @@ const sendEmail = async ({ return mailer .sendMail({ subject: subject, - templateName: templateName, - to: to, templateData: { appName: config.appName, ...templateData, }, + templateName: templateName, + to: to, }) .catch((error: Error) => { log.error(error.stack); diff --git a/packages/user/src/lib/sendInvitation.ts b/packages/user/src/lib/sendInvitation.ts index ac462ee5f..cf958fd66 100644 --- a/packages/user/src/lib/sendInvitation.ts +++ b/packages/user/src/lib/sendInvitation.ts @@ -1,10 +1,11 @@ +import type { FastifyInstance } from "fastify"; + +import type { Invitation } from "../types/invitation"; + import getInvitationLink from "./getInvitationLink"; import getOrigin from "./getOrigin"; import sendEmail from "./sendEmail"; -import type { Invitation } from "../types/invitation"; -import type { FastifyInstance } from "fastify"; - const sendInvitation = async ( fastify: FastifyInstance, invitation: Invitation, @@ -24,8 +25,8 @@ const sendInvitation = async ( config.user.emailOverrides?.invitation?.subject || "Invitation for sign up", templateData: { - invitationLink: getInvitationLink(config, invitation, origin), invitation, + invitationLink: getInvitationLink(config, invitation, origin), }, templateName: config.user.emailOverrides?.invitation?.templateName || diff --git a/packages/user/src/mercurius-auth/authPlugin.ts b/packages/user/src/mercurius-auth/authPlugin.ts index 34ab9cb54..5b91a5662 100644 --- a/packages/user/src/mercurius-auth/authPlugin.ts +++ b/packages/user/src/mercurius-auth/authPlugin.ts @@ -1,3 +1,5 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { mercurius } from "mercurius"; import mercuriusAuth from "mercurius-auth"; @@ -7,8 +9,6 @@ import { Error } from "supertokens-node/recipe/session"; import createUserContext from "../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../supertokens/utils/profileValidationClaim"; -import type { FastifyInstance } from "fastify"; - const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { await fastify.register(mercuriusAuth, { async applyPolicy(authDirectiveAST, parent, arguments_, context) { @@ -39,9 +39,9 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { { id: "st-ev", reason: { - message: "wrong value", - expectedValue: true, actualValue: false, + expectedValue: true, + message: "wrong value", }, }, ], diff --git a/packages/user/src/mercurius-auth/hasPermissionPlugin.ts b/packages/user/src/mercurius-auth/hasPermissionPlugin.ts index 9057afda4..96549e38b 100644 --- a/packages/user/src/mercurius-auth/hasPermissionPlugin.ts +++ b/packages/user/src/mercurius-auth/hasPermissionPlugin.ts @@ -1,11 +1,11 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { mercurius } from "mercurius"; import mercuriusAuth from "mercurius-auth"; import hasUserPermission from "../lib/hasUserPermission"; -import type { FastifyInstance } from "fastify"; - const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { await fastify.register(mercuriusAuth, { applyPolicy: async (authDirectiveAST, parent, arguments_, context) => { @@ -34,8 +34,8 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { { id: "st-perm", reason: { - message: "Not have enough permission", expectedToInclude: permission, + message: "Not have enough permission", }, }, ], diff --git a/packages/user/src/mercurius-auth/plugin.ts b/packages/user/src/mercurius-auth/plugin.ts index 9ec865357..55ba67962 100644 --- a/packages/user/src/mercurius-auth/plugin.ts +++ b/packages/user/src/mercurius-auth/plugin.ts @@ -1,10 +1,10 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import authPlugin from "./authPlugin"; import hasPermissionPlugin from "./hasPermissionPlugin"; -import type { FastifyInstance } from "fastify"; - const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { if (fastify.config.graphql?.enabled) { await fastify.register(hasPermissionPlugin); diff --git a/packages/user/src/middlewares/__test__/hasPermission.spec.ts b/packages/user/src/middlewares/__test__/hasPermission.spec.ts new file mode 100644 index 000000000..a6494d198 --- /dev/null +++ b/packages/user/src/middlewares/__test__/hasPermission.spec.ts @@ -0,0 +1,92 @@ +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +import Fastify, { type FastifyInstance } from "fastify"; +import { Error as STError } from "supertokens-node/recipe/session"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import hasPermission from "../hasPermission"; + +const { mockHasUserPermission } = vi.hoisted(() => ({ + mockHasUserPermission: vi.fn(), +})); + +vi.mock("../../lib/hasUserPermission", () => ({ + default: mockHasUserPermission, +})); + +const buildRequest = ( + fastify: FastifyInstance, + user?: { id: string }, +): SessionRequest => { + return { + server: fastify, + user, + } as unknown as SessionRequest; +}; + +describe("hasPermission middleware", () => { + let fastify: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fastify = Fastify({ logger: false }); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it("throws UNAUTHORISED when request has no authenticated user", async () => { + const preHandler = hasPermission("users:list"); + + await expect(preHandler(buildRequest(fastify))).rejects.toMatchObject({ + message: "unauthorised", + type: "UNAUTHORISED", + }); + }); + + it("throws INVALID_CLAIMS when user is missing permission", async () => { + mockHasUserPermission.mockResolvedValue(false); + const permission = "users:disable"; + const preHandler = hasPermission(permission); + + await expect( + preHandler(buildRequest(fastify, { id: "user-1" })), + ).rejects.toMatchObject({ + message: "Not have enough permission", + payload: [ + { + reason: { + expectedToInclude: permission, + message: "Not have enough permission", + }, + }, + ], + type: "INVALID_CLAIMS", + }); + expect(mockHasUserPermission.mock.calls.length).toBe(1); + expect(mockHasUserPermission.mock.calls[0]?.[1]).toBe("user-1"); + expect(mockHasUserPermission.mock.calls[0]?.[2]).toBe(permission); + }); + + it("allows request when user has required permission", async () => { + mockHasUserPermission.mockResolvedValue(true); + const preHandler = hasPermission("users:read"); + + await expect( + preHandler(buildRequest(fastify, { id: "user-42" })), + ).resolves.toBeUndefined(); + expect(mockHasUserPermission.mock.calls.length).toBe(1); + expect(mockHasUserPermission.mock.calls[0]?.[1]).toBe("user-42"); + expect(mockHasUserPermission.mock.calls[0]?.[2]).toBe("users:read"); + }); + + it("throws SuperTokens errors for unauthorized outcomes", async () => { + mockHasUserPermission.mockResolvedValue(false); + const preHandler = hasPermission("roles:update"); + + await expect( + preHandler(buildRequest(fastify, { id: "user-7" })), + ).rejects.toBeInstanceOf(STError); + }); +}); diff --git a/packages/user/src/middlewares/hasPermission.ts b/packages/user/src/middlewares/hasPermission.ts index 2873d0506..d7510b7f2 100644 --- a/packages/user/src/middlewares/hasPermission.ts +++ b/packages/user/src/middlewares/hasPermission.ts @@ -1,10 +1,10 @@ +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { Error as STError } from "supertokens-node/recipe/session"; import UserRoles from "supertokens-node/recipe/userroles"; import hasUserPermission from "../lib/hasUserPermission"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const hasPermission = (permission: string) => async (request: SessionRequest): Promise => { @@ -12,25 +12,25 @@ const hasPermission = if (!user) { throw new STError({ - type: "UNAUTHORISED", message: "unauthorised", + type: "UNAUTHORISED", }); } if (!(await hasUserPermission(request.server, user.id, permission))) { // this error tells SuperTokens to return a 403 http response. throw new STError({ - type: "INVALID_CLAIMS", message: "Not have enough permission", payload: [ { id: UserRoles.PermissionClaim.key, reason: { - message: "Not have enough permission", expectedToInclude: permission, + message: "Not have enough permission", }, }, ], + type: "INVALID_CLAIMS", }); } }; diff --git a/packages/user/src/migrations/queries.ts b/packages/user/src/migrations/queries.ts index b493132ef..d94c0a698 100644 --- a/packages/user/src/migrations/queries.ts +++ b/packages/user/src/migrations/queries.ts @@ -1,12 +1,12 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import type { QuerySqlToken } from "slonik"; +import type { ZodTypeAny } from "zod"; + import { TABLE_FILES } from "@prefabs.tech/fastify-s3"; import { sql } from "slonik"; import { TABLE_INVITATIONS, TABLE_USERS } from "../constants"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; -import type { QuerySqlToken } from "slonik"; -import type { ZodTypeAny } from "zod"; - const createInvitationsTableQuery = ( config: ApiConfig, ): QuerySqlToken => { diff --git a/packages/user/src/migrations/runMigrations.ts b/packages/user/src/migrations/runMigrations.ts index af45207d6..a79c9c464 100644 --- a/packages/user/src/migrations/runMigrations.ts +++ b/packages/user/src/migrations/runMigrations.ts @@ -1,8 +1,8 @@ -import { createInvitationsTableQuery, createUsersTableQuery } from "./queries"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { Database } from "@prefabs.tech/fastify-slonik"; +import { createInvitationsTableQuery, createUsersTableQuery } from "./queries"; + const runMigrations = async (config: ApiConfig, database: Database) => { await database.connect(async (connection) => { await connection.transaction(async (transactionConnection) => { diff --git a/packages/user/src/model/invitations/controller.ts b/packages/user/src/model/invitations/controller.ts index 3eb5417e6..14795e16c 100644 --- a/packages/user/src/model/invitations/controller.ts +++ b/packages/user/src/model/invitations/controller.ts @@ -1,13 +1,5 @@ -import handlers from "./handlers"; -import { - acceptInvitationSchema, - createInvitationSchema, - deleteInvitationSchema, - getInvitationByTokenSchema, - getInvitationsListSchema, - resendInvitationSchema, - revokeInvitationSchema, -} from "./schema"; +import type { FastifyInstance } from "fastify"; + import { PERMISSIONS_INVITATIONS_CREATE, PERMISSIONS_INVITATIONS_DELETE, @@ -22,8 +14,16 @@ import { ROUTE_INVITATIONS_RESEND, ROUTE_INVITATIONS_REVOKE, } from "../../constants"; - -import type { FastifyInstance } from "fastify"; +import handlers from "./handlers"; +import { + acceptInvitationSchema, + createInvitationSchema, + deleteInvitationSchema, + getInvitationByTokenSchema, + getInvitationsListSchema, + resendInvitationSchema, + revokeInvitationSchema, +} from "./schema"; const plugin = async (fastify: FastifyInstance) => { const handlersConfig = fastify.config.user.handlers?.invitation; diff --git a/packages/user/src/model/invitations/graphql/resolver.ts b/packages/user/src/model/invitations/graphql/resolver.ts index 90962d5c4..876bd49ed 100644 --- a/packages/user/src/model/invitations/graphql/resolver.ts +++ b/packages/user/src/model/invitations/graphql/resolver.ts @@ -1,21 +1,22 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { MercuriusContext } from "mercurius"; + import { formatDate } from "@prefabs.tech/fastify-slonik"; import { mercurius } from "mercurius"; import { createNewSession } from "supertokens-node/recipe/session"; import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; -import getInvitationService from "../../../lib/getInvitationService"; -import isInvitationValid from "../../../lib/isInvitationValid"; -import sendInvitation from "../../../lib/sendInvitation"; -import validateEmail from "../../../validator/email"; -import validatePassword from "../../../validator/password"; - import type { User } from "../../../types"; import type { Invitation, InvitationCreateInput, } from "../../../types/invitation"; -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; -import type { MercuriusContext } from "mercurius"; + +import getInvitationService from "../../../lib/getInvitationService"; +import isInvitationValid from "../../../lib/isInvitationValid"; +import sendInvitation from "../../../lib/sendInvitation"; +import validateEmail from "../../../validator/email"; +import validatePassword from "../../../validator/password"; const Mutation = { acceptInvitation: async ( @@ -31,7 +32,7 @@ const Mutation = { ) => { const { app, config, database, dbSchema, reply } = context; - const { token, data } = arguments_; + const { data, token } = arguments_; try { const { email, password } = data; @@ -82,8 +83,8 @@ const Mutation = { // signup const signUpResponse = await emailPasswordSignUp(email, password, { - roles: [invitation.role], autoVerifyEmail: true, + roles: [invitation.role], }); if (signUpResponse.status !== "OK") { @@ -192,6 +193,35 @@ const Mutation = { ); } }, + deleteInvitation: async ( + parent: unknown, + arguments_: { + id: number; + }, + context: MercuriusContext, + ) => { + const service = getInvitationService( + context.config, + context.database, + context.dbSchema, + ); + + const invitation = await service.delete(arguments_.id); + + let errorMessage: string | undefined; + + if (!invitation) { + errorMessage = "Invitation not found"; + } + + if (errorMessage) { + const mercuriusError = new mercurius.ErrorWithProps(errorMessage); + + return mercuriusError; + } + + return invitation; + }, resendInvitation: async ( parent: unknown, arguments_: { @@ -263,35 +293,6 @@ const Mutation = { revokedAt: formatDate(new Date(Date.now())), }); - return invitation; - }, - deleteInvitation: async ( - parent: unknown, - arguments_: { - id: number; - }, - context: MercuriusContext, - ) => { - const service = getInvitationService( - context.config, - context.database, - context.dbSchema, - ); - - const invitation = await service.delete(arguments_.id); - - let errorMessage: string | undefined; - - if (!invitation) { - errorMessage = "Invitation not found"; - } - - if (errorMessage) { - const mercuriusError = new mercurius.ErrorWithProps(errorMessage); - - return mercuriusError; - } - return invitation; }, }; @@ -327,9 +328,9 @@ const Query = { invitations: async ( parent: unknown, arguments_: { + filters?: FilterInput; limit: number; offset: number; - filters?: FilterInput; sort?: SortInput[]; }, context: MercuriusContext, diff --git a/packages/user/src/model/invitations/handlers/acceptInvitation.ts b/packages/user/src/model/invitations/handlers/acceptInvitation.ts index 59d603376..eb0fdfa4a 100644 --- a/packages/user/src/model/invitations/handlers/acceptInvitation.ts +++ b/packages/user/src/model/invitations/handlers/acceptInvitation.ts @@ -1,15 +1,16 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + import { formatDate } from "@prefabs.tech/fastify-slonik"; import { createNewSession } from "supertokens-node/recipe/session"; import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { User } from "../../../types"; + import getInvitationService from "../../../lib/getInvitationService"; import isInvitationValid from "../../../lib/isInvitationValid"; import validateEmail from "../../../validator/email"; import validatePassword from "../../../validator/password"; -import type { User } from "../../../types"; -import type { FastifyReply, FastifyRequest } from "fastify"; - interface FieldInput { email: string; password: string; @@ -66,8 +67,8 @@ const acceptInvitation = async ( // signup const signUpResponse = await emailPasswordSignUp(email, password, { - roles: [invitation.role], autoVerifyEmail: true, + roles: [invitation.role], }); if (signUpResponse.status !== "OK") { diff --git a/packages/user/src/model/invitations/handlers/createInvitation.ts b/packages/user/src/model/invitations/handlers/createInvitation.ts index 6de4e043b..08e31b378 100644 --- a/packages/user/src/model/invitations/handlers/createInvitation.ts +++ b/packages/user/src/model/invitations/handlers/createInvitation.ts @@ -1,12 +1,13 @@ -import getInvitationService from "../../../lib/getInvitationService"; -import sendInvitation from "../../../lib/sendInvitation"; +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; import type { Invitation, InvitationCreateInput, } from "../../../types/invitation"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; + +import getInvitationService from "../../../lib/getInvitationService"; +import sendInvitation from "../../../lib/sendInvitation"; const createInvitation = async ( request: SessionRequest, diff --git a/packages/user/src/model/invitations/handlers/deleteInvitation.ts b/packages/user/src/model/invitations/handlers/deleteInvitation.ts index c013d8ee2..c90133c1f 100644 --- a/packages/user/src/model/invitations/handlers/deleteInvitation.ts +++ b/packages/user/src/model/invitations/handlers/deleteInvitation.ts @@ -1,9 +1,10 @@ -import Service from "../service"; - -import type { Invitation } from "../../../types/invitation"; import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { Invitation } from "../../../types/invitation"; + +import Service from "../service"; + const deleteInvitation = async ( request: SessionRequest, reply: FastifyReply, diff --git a/packages/user/src/model/invitations/handlers/getInvitationByToken.ts b/packages/user/src/model/invitations/handlers/getInvitationByToken.ts index a250bad2d..94e51b7ee 100644 --- a/packages/user/src/model/invitations/handlers/getInvitationByToken.ts +++ b/packages/user/src/model/invitations/handlers/getInvitationByToken.ts @@ -1,7 +1,7 @@ -import getInvitationService from "../../../lib/getInvitationService"; - import type { FastifyReply, FastifyRequest } from "fastify"; +import getInvitationService from "../../../lib/getInvitationService"; + const getInvitationByToken = async ( request: FastifyRequest, reply: FastifyReply, diff --git a/packages/user/src/model/invitations/handlers/listInvitation.ts b/packages/user/src/model/invitations/handlers/listInvitation.ts index 3beeeaf5e..45ede5370 100644 --- a/packages/user/src/model/invitations/handlers/listInvitation.ts +++ b/packages/user/src/model/invitations/handlers/listInvitation.ts @@ -1,17 +1,18 @@ -import getInvitationService from "../../../lib/getInvitationService"; - -import type { Invitation } from "../../../types/invitation"; import type { PaginatedList } from "@prefabs.tech/fastify-slonik"; import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { Invitation } from "../../../types/invitation"; + +import getInvitationService from "../../../lib/getInvitationService"; + const listInvitation = async (request: SessionRequest, reply: FastifyReply) => { const { config, dbSchema, query, slonik } = request; - const { limit, offset, filters, sort } = query as { + const { filters, limit, offset, sort } = query as { + filters?: string; limit: number; offset?: number; - filters?: string; sort?: string; }; diff --git a/packages/user/src/model/invitations/handlers/resendInvitation.ts b/packages/user/src/model/invitations/handlers/resendInvitation.ts index de6396a54..09c1c1fb7 100644 --- a/packages/user/src/model/invitations/handlers/resendInvitation.ts +++ b/packages/user/src/model/invitations/handlers/resendInvitation.ts @@ -1,16 +1,17 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + +import type { Invitation } from "../../../types/invitation"; + import getInvitationService from "../../../lib/getInvitationService"; import isInvitationValid from "../../../lib/isInvitationValid"; import sendInvitation from "../../../lib/sendInvitation"; -import type { Invitation } from "../../../types/invitation"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const resendInvitation = async ( request: SessionRequest, reply: FastifyReply, ) => { - const { config, dbSchema, headers, hostname, log, params, slonik, server } = + const { config, dbSchema, headers, hostname, log, params, server, slonik } = request; const { id } = params as { id: string }; @@ -22,8 +23,8 @@ const resendInvitation = async ( // is invitation valid if (!invitation || !isInvitationValid(invitation)) { return reply.send({ - status: "ERROR", message: "Invitation is invalid or has expired", + status: "ERROR", }); } diff --git a/packages/user/src/model/invitations/handlers/revokeInvitation.ts b/packages/user/src/model/invitations/handlers/revokeInvitation.ts index e4869fa5f..b39fccf3f 100644 --- a/packages/user/src/model/invitations/handlers/revokeInvitation.ts +++ b/packages/user/src/model/invitations/handlers/revokeInvitation.ts @@ -1,10 +1,11 @@ -import { formatDate } from "@prefabs.tech/fastify-slonik"; +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; -import getInvitationService from "../../../lib/getInvitationService"; +import { formatDate } from "@prefabs.tech/fastify-slonik"; import type { Invitation } from "../../../types/invitation"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; + +import getInvitationService from "../../../lib/getInvitationService"; const revokeInvitation = async ( request: SessionRequest, diff --git a/packages/user/src/model/invitations/schema.ts b/packages/user/src/model/invitations/schema.ts index 7f08d4313..7471e996c 100644 --- a/packages/user/src/model/invitations/schema.ts +++ b/packages/user/src/model/invitations/schema.ts @@ -1,41 +1,40 @@ const invitationCreateInputSchema = { - type: "object", properties: { - appId: { type: "integer", nullable: true }, - email: { type: "string", format: "email" }, - payload: { type: "object", additionalProperties: true, nullable: true }, + appId: { nullable: true, type: "integer" }, + email: { format: "email", type: "string" }, + payload: { additionalProperties: true, nullable: true, type: "object" }, role: { type: "string" }, }, required: ["email", "role"], + type: "object", }; const userSchema = { - type: "object", + additionalProperties: true, properties: { + email: { format: "email", type: "string" }, id: { type: "string" }, - email: { type: "string", format: "email" }, }, - additionalProperties: true, + type: "object", }; const invitationSchema = { - type: "object", properties: { - id: { type: "integer" }, - acceptedAt: { type: "integer", nullable: true }, - appId: { type: "integer", nullable: true }, - email: { type: "string", format: "email" }, + acceptedAt: { nullable: true, type: "integer" }, + appId: { nullable: true, type: "integer" }, + createdAt: { type: "integer" }, + email: { format: "email", type: "string" }, expiresAt: { type: "integer" }, + id: { type: "integer" }, invitedBy: { ...userSchema, nullable: true, }, invitedById: { type: "string" }, - payload: { type: "object", additionalProperties: true, nullable: true }, - revokedAt: { type: "integer", nullable: true }, + payload: { additionalProperties: true, nullable: true, type: "object" }, + revokedAt: { nullable: true, type: "integer" }, role: { type: "string" }, token: { type: "string" }, - createdAt: { type: "integer" }, updatedAt: { type: "integer" }, }, required: [ @@ -47,45 +46,46 @@ const invitationSchema = { "createdAt", "updatedAt", ], + type: "object", }; export const acceptInvitationSchema = { - description: "Accept an invitation using the invitation token", - operationId: "acceptInvitation", body: { - type: "object", properties: { - email: { type: "string", format: "email" }, - password: { type: "string", format: "password" }, + email: { format: "email", type: "string" }, + password: { format: "password", type: "string" }, }, required: ["email", "password"], + type: "object", }, + description: "Accept an invitation using the invitation token", + operationId: "acceptInvitation", params: { - type: "object", properties: { token: { type: "string" }, }, required: ["token"], + type: "object", }, response: { 200: { - type: "object", properties: { status: { type: "string" }, user: userSchema, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -95,22 +95,22 @@ export const acceptInvitationSchema = { }; export const createInvitationSchema = { + body: invitationCreateInputSchema, description: "Create a new invitation", operationId: "createInvitation", - body: invitationCreateInputSchema, response: { 200: invitationSchema, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -123,29 +123,29 @@ export const deleteInvitationSchema = { description: "Delete an invitation by ID", operationId: "deleteInvitation", params: { - type: "object", - required: ["id"], properties: { id: { type: "integer" }, }, + required: ["id"], + type: "object", }, response: { 200: invitationSchema, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Invitation not found", $ref: "ErrorResponse#", + description: "Invitation not found", }, 500: { $ref: "ErrorResponse#", @@ -158,11 +158,11 @@ export const getInvitationByTokenSchema = { description: "Get invitation details by token", operationId: "getInvitationByToken", params: { - type: "object", - required: ["token"], properties: { token: { type: "string" }, }, + required: ["token"], + type: "object", }, response: { 200: { @@ -170,16 +170,16 @@ export const getInvitationByTokenSchema = { nullable: true, }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Invitation not found", $ref: "ErrorResponse#", + description: "Invitation not found", }, 500: { $ref: "ErrorResponse#", @@ -192,35 +192,35 @@ export const getInvitationsListSchema = { description: "Get a paginated list of invitations", operationId: "getInvitationsList", querystring: { - type: "object", properties: { + filters: { type: "string" }, limit: { type: "number" }, offset: { type: "number" }, - filters: { type: "string" }, sort: { type: "string" }, }, + type: "object", }, response: { 200: { description: "List of paginated list of invitations", - type: "object", - required: ["totalCount", "filteredCount", "data"], properties: { - totalCount: { type: "integer" }, - filteredCount: { type: "integer" }, data: { - type: "array", items: invitationSchema, + type: "array", }, + filteredCount: { type: "integer" }, + totalCount: { type: "integer" }, }, + required: ["totalCount", "filteredCount", "data"], + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -233,40 +233,40 @@ export const resendInvitationSchema = { description: "Resend an invitation by ID", operationId: "resendInvitation", params: { - type: "object", - required: ["id"], properties: { id: { type: "integer" }, }, + required: ["id"], + type: "object", }, response: { 200: { oneOf: [ invitationSchema, { - type: "object", properties: { - status: { type: "string", const: "ERROR" }, message: { type: "string" }, + status: { const: "ERROR", type: "string" }, }, + type: "object", }, ], }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Invitation not found", $ref: "ErrorResponse#", + description: "Invitation not found", }, 500: { $ref: "ErrorResponse#", @@ -279,29 +279,29 @@ export const revokeInvitationSchema = { description: "Revoke an invitation by ID", operationId: "revokeInvitation", params: { - type: "object", - required: ["id"], properties: { id: { type: "integer" }, }, + required: ["id"], + type: "object", }, response: { 200: invitationSchema, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Invitation not found", $ref: "ErrorResponse#", + description: "Invitation not found", }, 500: { $ref: "ErrorResponse#", @@ -311,35 +311,35 @@ export const revokeInvitationSchema = { }; export const updateInvitationSchema = { - description: "Update an invitation", - operationId: "updateInvitation", body: { - type: "object", - required: ["email", "status"], properties: { - email: { type: "string", format: "email" }, - status: { type: "string", enum: ["accepted", "declined"] }, + email: { format: "email", type: "string" }, + status: { enum: ["accepted", "declined"], type: "string" }, }, + required: ["email", "status"], + type: "object", }, + description: "Update an invitation", + operationId: "updateInvitation", response: { 200: { description: "Invitation updated successfully", - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/user/src/model/invitations/service.ts b/packages/user/src/model/invitations/service.ts index 817ecc409..8f07ee624 100644 --- a/packages/user/src/model/invitations/service.ts +++ b/packages/user/src/model/invitations/service.ts @@ -1,25 +1,34 @@ -import { CustomError } from "@prefabs.tech/fastify-error-handler"; -import { formatDate, BaseService } from "@prefabs.tech/fastify-slonik"; +import type { FilterInput } from "@prefabs.tech/fastify-slonik"; -import InvitationSqlFactory from "./sqlFactory"; -import { ERROR_CODES } from "../../constants"; -import computeInvitationExpiresAt from "../../lib/computeInvitationExpiresAt"; -import getUserService from "../../lib/getUserService"; -import areRolesExist from "../../supertokens/utils/areRolesExist"; -import validateEmail from "../../validator/email"; +import { CustomError } from "@prefabs.tech/fastify-error-handler"; +import { BaseService, formatDate } from "@prefabs.tech/fastify-slonik"; import type { Invitation, InvitationCreateInput, InvitationUpdateInput, } from "../../types"; -import type { FilterInput } from "@prefabs.tech/fastify-slonik"; + +import { ERROR_CODES } from "../../constants"; +import computeInvitationExpiresAt from "../../lib/computeInvitationExpiresAt"; +import getUserService from "../../lib/getUserService"; +import areRolesExist from "../../supertokens/utils/areRolesExist"; +import validateEmail from "../../validator/email"; +import InvitationSqlFactory from "./sqlFactory"; class InvitationService extends BaseService< Invitation, InvitationCreateInput, InvitationUpdateInput > { + get factory(): InvitationSqlFactory { + return super.factory as InvitationSqlFactory; + } + + get sqlFactoryClass() { + return InvitationSqlFactory; + } + async findByToken(token: string): Promise { if (!this.validateUUID(token)) { // eslint-disable-next-line unicorn/no-null @@ -35,14 +44,6 @@ class InvitationService extends BaseService< return result; } - get factory(): InvitationSqlFactory { - return super.factory as InvitationSqlFactory; - } - - get sqlFactoryClass() { - return InvitationSqlFactory; - } - protected async preCreate( data: InvitationCreateInput, ): Promise { diff --git a/packages/user/src/model/invitations/sqlFactory.ts b/packages/user/src/model/invitations/sqlFactory.ts index 0f47ceffd..62ef70237 100644 --- a/packages/user/src/model/invitations/sqlFactory.ts +++ b/packages/user/src/model/invitations/sqlFactory.ts @@ -1,15 +1,19 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { FragmentSqlToken, QuerySqlToken } from "slonik"; + import { DefaultSqlFactory } from "@prefabs.tech/fastify-slonik"; import { sql } from "slonik"; import { TABLE_INVITATIONS } from "../../constants"; import UserSqlFactory from "../users/sqlFactory"; -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; -import type { FragmentSqlToken, QuerySqlToken } from "slonik"; - class InvitationSqlFactory extends DefaultSqlFactory { static readonly TABLE = TABLE_INVITATIONS; + get table() { + return this.config.user?.tables?.invitations?.name || super.table; + } + getFindByTokenSql = (token: string): QuerySqlToken => { return sql.type(this.validationSchema)` SELECT * @@ -43,10 +47,6 @@ class InvitationSqlFactory extends DefaultSqlFactory { return userSqlFactory.tableFragment; } - - get table() { - return this.config.user?.tables?.invitations?.name || super.table; - } } export default InvitationSqlFactory; diff --git a/packages/user/src/model/permissions/controller.ts b/packages/user/src/model/permissions/controller.ts index 97f26e5dd..f7bd416fd 100644 --- a/packages/user/src/model/permissions/controller.ts +++ b/packages/user/src/model/permissions/controller.ts @@ -1,8 +1,8 @@ +import type { FastifyInstance } from "fastify"; + +import { ROUTE_PERMISSIONS } from "../../constants"; import handlers from "./handlers"; import { getPermissionsSchema } from "./schema"; -import { ROUTE_PERMISSIONS } from "../../constants"; - -import type { FastifyInstance } from "fastify"; const plugin = async (fastify: FastifyInstance) => { fastify.get( diff --git a/packages/user/src/model/permissions/resolver.ts b/packages/user/src/model/permissions/resolver.ts index 52011d720..002d8f0c1 100644 --- a/packages/user/src/model/permissions/resolver.ts +++ b/packages/user/src/model/permissions/resolver.ts @@ -1,7 +1,7 @@ -import { mercurius } from "mercurius"; - import type { MercuriusContext } from "mercurius"; +import { mercurius } from "mercurius"; + const Query = { permissions: async ( parent: unknown, diff --git a/packages/user/src/model/permissions/schema.ts b/packages/user/src/model/permissions/schema.ts index 12de22465..0f87301de 100644 --- a/packages/user/src/model/permissions/schema.ts +++ b/packages/user/src/model/permissions/schema.ts @@ -3,23 +3,23 @@ export const getPermissionsSchema = { operationId: "getPermissions", response: { 200: { - type: "object", properties: { permissions: { - type: "array", items: { type: "string", }, + type: "array", }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/user/src/model/roles/controller.ts b/packages/user/src/model/roles/controller.ts index 557869362..b41971f49 100644 --- a/packages/user/src/model/roles/controller.ts +++ b/packages/user/src/model/roles/controller.ts @@ -1,3 +1,6 @@ +import type { FastifyInstance } from "fastify"; + +import { ROUTE_ROLES, ROUTE_ROLES_PERMISSIONS } from "../../constants"; import handlers from "./handlers"; import { createRoleSchema, @@ -6,9 +9,6 @@ import { getRolesSchema, updateRoleSchema, } from "./schema"; -import { ROUTE_ROLES, ROUTE_ROLES_PERMISSIONS } from "../../constants"; - -import type { FastifyInstance } from "fastify"; const plugin = async (fastify: FastifyInstance) => { fastify.delete( diff --git a/packages/user/src/model/roles/graphql/resolver.ts b/packages/user/src/model/roles/graphql/resolver.ts index cb048811e..6523460b0 100644 --- a/packages/user/src/model/roles/graphql/resolver.ts +++ b/packages/user/src/model/roles/graphql/resolver.ts @@ -1,16 +1,16 @@ +import type { MercuriusContext } from "mercurius"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { mercurius } from "mercurius"; import RoleService from "../service"; -import type { MercuriusContext } from "mercurius"; - const Mutation = { createRole: async ( parent: unknown, arguments_: { - role: string; permissions: string[]; + role: string; }, context: MercuriusContext, ) => { @@ -87,8 +87,8 @@ const Mutation = { updateRolePermissions: async ( parent: unknown, arguments_: { - role: string; permissions: string[]; + role: string; }, context: MercuriusContext, ) => { @@ -126,18 +126,25 @@ const Mutation = { }; const Query = { - roles: async ( + rolePermissions: async ( parent: unknown, - arguments_: Record, + arguments_: { + role: string; + }, context: MercuriusContext, ) => { const { app } = context; + const { role } = arguments_; + let permissions: string[] = []; try { - const service = new RoleService(); - const roles = await service.getRoles(); + if (role) { + const service = new RoleService(); - return roles; + permissions = await service.getPermissionsForRole(role); + } + + return permissions; } catch (error) { app.log.error(error); @@ -150,25 +157,18 @@ const Query = { return mercuriusError; } }, - rolePermissions: async ( + roles: async ( parent: unknown, - arguments_: { - role: string; - }, + arguments_: Record, context: MercuriusContext, ) => { const { app } = context; - const { role } = arguments_; - let permissions: string[] = []; try { - if (role) { - const service = new RoleService(); - - permissions = await service.getPermissionsForRole(role); - } + const service = new RoleService(); + const roles = await service.getRoles(); - return permissions; + return roles; } catch (error) { app.log.error(error); diff --git a/packages/user/src/model/roles/handlers/createRole.ts b/packages/user/src/model/roles/handlers/createRole.ts index b4c917a84..22cda0fbe 100644 --- a/packages/user/src/model/roles/handlers/createRole.ts +++ b/packages/user/src/model/roles/handlers/createRole.ts @@ -1,16 +1,16 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import RoleService from "../service"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const createRole = async (request: SessionRequest, reply: FastifyReply) => { const { body } = request; - const { role, permissions } = body as { - role: string; + const { permissions, role } = body as { permissions: string[]; + role: string; }; try { diff --git a/packages/user/src/model/roles/handlers/deleteRole.ts b/packages/user/src/model/roles/handlers/deleteRole.ts index c6f7f187f..dddd384e7 100644 --- a/packages/user/src/model/roles/handlers/deleteRole.ts +++ b/packages/user/src/model/roles/handlers/deleteRole.ts @@ -1,11 +1,11 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { ERROR_CODES } from "../../../constants"; import RoleService from "../service"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const deleteRole = async (request: SessionRequest, reply: FastifyReply) => { const { query } = request; diff --git a/packages/user/src/model/roles/handlers/getPermissions.ts b/packages/user/src/model/roles/handlers/getPermissions.ts index b6cd1fb57..2fe21f203 100644 --- a/packages/user/src/model/roles/handlers/getPermissions.ts +++ b/packages/user/src/model/roles/handlers/getPermissions.ts @@ -1,8 +1,8 @@ -import RoleService from "../service"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import RoleService from "../service"; + const getPermissions = async (request: SessionRequest, reply: FastifyReply) => { const { query } = request; let permissions: string[] = []; diff --git a/packages/user/src/model/roles/handlers/getRoles.ts b/packages/user/src/model/roles/handlers/getRoles.ts index a5a5b0b4e..40abe3db3 100644 --- a/packages/user/src/model/roles/handlers/getRoles.ts +++ b/packages/user/src/model/roles/handlers/getRoles.ts @@ -1,8 +1,8 @@ -import RoleService from "../service"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import RoleService from "../service"; + const getRoles = async (request: SessionRequest, reply: FastifyReply) => { const service = new RoleService(); const roles = await service.getRoles(); diff --git a/packages/user/src/model/roles/handlers/index.ts b/packages/user/src/model/roles/handlers/index.ts index 9176aedec..d3ab61b3f 100644 --- a/packages/user/src/model/roles/handlers/index.ts +++ b/packages/user/src/model/roles/handlers/index.ts @@ -5,9 +5,9 @@ import getRoles from "./getRoles"; import updatePermissions from "./updatePermissions"; export default { - deleteRole, createRole, - getRoles, + deleteRole, getPermissions, + getRoles, updatePermissions, }; diff --git a/packages/user/src/model/roles/handlers/updatePermissions.ts b/packages/user/src/model/roles/handlers/updatePermissions.ts index 07d8884c1..8dc30eae7 100644 --- a/packages/user/src/model/roles/handlers/updatePermissions.ts +++ b/packages/user/src/model/roles/handlers/updatePermissions.ts @@ -1,10 +1,10 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import RoleService from "../service"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const updatePermissions = async ( request: SessionRequest, reply: FastifyReply, @@ -12,9 +12,9 @@ const updatePermissions = async ( const { body } = request; try { - const { role, permissions } = body as { - role: string; + const { permissions, role } = body as { permissions: string[]; + role: string; }; const service = new RoleService(); diff --git a/packages/user/src/model/roles/schema.ts b/packages/user/src/model/roles/schema.ts index b8e44bd6e..3b9ebb0a2 100644 --- a/packages/user/src/model/roles/schema.ts +++ b/packages/user/src/model/roles/schema.ts @@ -1,36 +1,36 @@ export const createRoleSchema = { - description: "Create a new role with optional permissions", - operationId: "createRole", body: { - type: "object", - required: ["role"], properties: { - role: { type: "string" }, permissions: { - type: "array", items: { type: "string" }, + type: "array", }, + role: { type: "string" }, }, + required: ["role"], + type: "object", }, + description: "Create a new role with optional permissions", + operationId: "createRole", response: { 201: { description: "Role created successfully", - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -43,10 +43,10 @@ export const deleteRoleSchema = { description: "Delete a role by name", operationId: "deleteRole", querystring: { - type: "object", properties: { role: { type: "string" }, }, + type: "object", }, response: { 200: { @@ -57,16 +57,16 @@ export const deleteRoleSchema = { type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 422: { - description: "Unprocessable Entity", $ref: "ErrorResponse#", + description: "Unprocessable Entity", }, 500: { $ref: "ErrorResponse#", @@ -79,33 +79,33 @@ export const getRolePermissionsSchema = { description: "Get permissions for a specific role", operationId: "getRolePermissions", querystring: { - type: "object", properties: { role: { type: "string" }, }, + type: "object", }, response: { 200: { description: "Role permissions retrieved successfully", - type: "object", properties: { permissions: { - type: "array", items: { type: "string" }, + type: "array", }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "Role not found", $ref: "ErrorResponse#", + description: "Role not found", }, 500: { $ref: "ErrorResponse#", @@ -119,30 +119,30 @@ export const getRolesSchema = { operationId: "getRoles", response: { 200: { - type: "object", properties: { roles: { - type: "array", items: { - type: "object", properties: { - role: { type: "string" }, permissions: { - type: "array", items: { type: "string" }, + type: "array", }, + role: { type: "string" }, }, + type: "object", }, + type: "array", }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -152,42 +152,42 @@ export const getRolesSchema = { }; export const updateRoleSchema = { - description: "Update a role's permissions", - operationId: "updateRole", body: { - type: "object", - required: ["role"], properties: { - role: { type: "string" }, permissions: { - type: "array", items: { type: "string" }, + type: "array", }, + role: { type: "string" }, }, + required: ["role"], + type: "object", }, + description: "Update a role's permissions", + operationId: "updateRole", response: { 200: { description: "Role updated successfully", - type: "object", properties: { - status: { type: "string" }, permissions: { - type: "array", items: { type: "string" }, + type: "array", }, + status: { type: "string" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/user/src/model/roles/service.ts b/packages/user/src/model/roles/service.ts index 15ebb4bdd..ae1171ba3 100644 --- a/packages/user/src/model/roles/service.ts +++ b/packages/user/src/model/roles/service.ts @@ -56,8 +56,8 @@ class RoleService { return permissions; } - async getRoles(): Promise<{ role: string; permissions: string[] }[]> { - let roles: { role: string; permissions: string[] }[] = []; + async getRoles(): Promise<{ permissions: string[]; role: string }[]> { + let roles: { permissions: string[]; role: string }[] = []; const response = await UserRoles.getAllRoles(); @@ -68,8 +68,8 @@ class RoleService { const response = await UserRoles.getPermissionsForRole(role); return { - role, permissions: response.status === "OK" ? response.permissions : [], + role, }; }), ); @@ -81,7 +81,7 @@ class RoleService { async updateRolePermissions( role: string, permissions: string[], - ): Promise<{ status: "OK"; permissions: string[] }> { + ): Promise<{ permissions: string[]; status: "OK" }> { const response = await UserRoles.getPermissionsForRole(role); if (response.status === "UNKNOWN_ROLE_ERROR") { @@ -104,8 +104,8 @@ class RoleService { const permissionsResponse = await this.getPermissionsForRole(role); return { - status: "OK", permissions: permissionsResponse, + status: "OK", }; } } diff --git a/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts b/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts index 00b1c127f..08d050ab8 100644 --- a/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts +++ b/packages/user/src/model/users/__test__/filterUserUpdateInput.spec.ts @@ -1,12 +1,12 @@ /* istanbul ignore file */ import { describe, expect, it } from "vitest"; -import filterUserUpdateInput from "../filterUserUpdateInput"; - import type { UserUpdateInput } from "../../../types"; -describe("removeUpdateProperties", () => { - it("should not remove valid input key", () => { +import filterUserUpdateInput from "../filterUserUpdateInput"; + +describe("filterUserUpdateInput", () => { + it("does not remove a valid mutable input key", () => { const updateInput = { middleNames: "A", } as UserUpdateInput; @@ -16,17 +16,57 @@ describe("removeUpdateProperties", () => { expect(updateInput).toHaveProperty("middleNames"); }); - it("should remove ignored input key", () => { + it("removes 'email'", () => { + const updateInput = { email: "user@example.com" } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("email"); + }); + + it("removes 'id'", () => { + const updateInput = { id: "some-id" } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("id"); + }); + + it("removes 'roles'", () => { + const updateInput = { roles: ["ADMIN"] } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("roles"); + }); + + it("removes 'disable'", () => { + const updateInput = { disable: true } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("disable"); + }); + + it("removes 'enable'", () => { + const updateInput = { enable: true } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("enable"); + }); + + it("removes camelCase 'lastLoginAt'", () => { const updateInput = { - email: "user@example.com", + lastLoginAt: "2023-06-13 04:02:45.825", } as UserUpdateInput; filterUserUpdateInput(updateInput); - expect(updateInput).not.toHaveProperty("email"); + expect(updateInput).not.toHaveProperty("lastLoginAt"); }); - it("should remove ignored input (tricky) key", () => { + it("removes mixed 'lastLogin_at' (camelized to lastLoginAt)", () => { const updateInput = { lastLogin_at: "2023-06-13 04:02:45.825", } as UserUpdateInput; @@ -36,17 +76,55 @@ describe("removeUpdateProperties", () => { expect(updateInput).not.toHaveProperty("lastLogin_at"); }); - it("should handle more than one input keys", () => { + it("removes snake_case 'last_login_at' (camelized to lastLoginAt)", () => { + const updateInput = { + last_login_at: "2023-06-13 04:02:45.825", + } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("last_login_at"); + }); + + it("removes camelCase 'signedUpAt'", () => { + const updateInput = { signedUpAt: 1_234_567_890 } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("signedUpAt"); + }); + + it("removes snake_case 'signed_up_at' (camelized to signedUpAt)", () => { + const updateInput = { signed_up_at: 1_234_567_890 } as UserUpdateInput; + + filterUserUpdateInput(updateInput); + + expect(updateInput).not.toHaveProperty("signed_up_at"); + }); + + it("removes blocked fields while preserving valid fields in a mixed input", () => { const updateInput = { email: "user@example.com", - lastLogin_at: "2023-06-13 04:02:45.825", + last_login_at: "2023-06-13 04:02:45.825", middleNames: "A", + photoId: 42, } as UserUpdateInput; filterUserUpdateInput(updateInput); expect(updateInput).toHaveProperty("middleNames"); + expect(updateInput).toHaveProperty("photoId"); expect(updateInput).not.toHaveProperty("email"); - expect(updateInput).not.toHaveProperty("lastLogin_at"); + expect(updateInput).not.toHaveProperty("last_login_at"); + }); + + it("mutates the input object in place", () => { + const updateInput = { email: "user@example.com" } as UserUpdateInput; + const reference = updateInput; + + filterUserUpdateInput(updateInput); + + // Same object reference — no new object created + expect(updateInput).toBe(reference); }); }); diff --git a/packages/user/src/model/users/controller.ts b/packages/user/src/model/users/controller.ts index be5db40a0..48f81e186 100644 --- a/packages/user/src/model/users/controller.ts +++ b/packages/user/src/model/users/controller.ts @@ -1,5 +1,23 @@ +import type { FastifyInstance } from "fastify"; + import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; +import { + PERMISSIONS_USERS_DISABLE, + PERMISSIONS_USERS_ENABLE, + PERMISSIONS_USERS_LIST, + PERMISSIONS_USERS_READ, + ROUTE_CHANGE_EMAIL, + ROUTE_CHANGE_PASSWORD, + ROUTE_ME, + ROUTE_ME_PHOTO, + ROUTE_SIGNUP_ADMIN, + ROUTE_USERS, + ROUTE_USERS_DISABLE, + ROUTE_USERS_ENABLE, + ROUTE_USERS_FIND_BY_ID, +} from "../../constants"; +import ProfileValidationClaim from "../../supertokens/utils/profileValidationClaim"; import handlers from "./handlers"; import { adminSignUpSchema, @@ -16,24 +34,6 @@ import { updateMeSchema, uploadPhotoSchema, } from "./schema"; -import { - PERMISSIONS_USERS_DISABLE, - PERMISSIONS_USERS_ENABLE, - PERMISSIONS_USERS_READ, - PERMISSIONS_USERS_LIST, - ROUTE_CHANGE_EMAIL, - ROUTE_CHANGE_PASSWORD, - ROUTE_SIGNUP_ADMIN, - ROUTE_ME, - ROUTE_USERS, - ROUTE_USERS_DISABLE, - ROUTE_USERS_ENABLE, - ROUTE_USERS_FIND_BY_ID, - ROUTE_ME_PHOTO, -} from "../../constants"; -import ProfileValidationClaim from "../../supertokens/utils/profileValidationClaim"; - -import type { FastifyInstance } from "fastify"; const plugin = async (fastify: FastifyInstance) => { const handlersConfig = fastify.config.user.handlers?.user; diff --git a/packages/user/src/model/users/dbFilters.ts b/packages/user/src/model/users/dbFilters.ts index 8463b9cce..d2f633aa3 100644 --- a/packages/user/src/model/users/dbFilters.ts +++ b/packages/user/src/model/users/dbFilters.ts @@ -1,12 +1,12 @@ -import { applyFilter } from "@prefabs.tech/fastify-slonik"; -import humps from "humps"; -import { sql } from "slonik"; - import type { BaseFilterInput, FilterInput, } from "@prefabs.tech/fastify-slonik"; -import type { IdentifierSqlToken, FragmentSqlToken } from "slonik"; +import type { FragmentSqlToken, IdentifierSqlToken } from "slonik"; + +import { applyFilter } from "@prefabs.tech/fastify-slonik"; +import humps from "humps"; +import { sql } from "slonik"; const applyFiltersToQuery = ( filters: FilterInput, diff --git a/packages/user/src/model/users/filterUserUpdateInput.ts b/packages/user/src/model/users/filterUserUpdateInput.ts index d312c3aa0..44861ca3b 100644 --- a/packages/user/src/model/users/filterUserUpdateInput.ts +++ b/packages/user/src/model/users/filterUserUpdateInput.ts @@ -3,10 +3,10 @@ import humps from "humps"; import type { UserUpdateInput } from "../../types"; const ignoredUpdateKeys = new Set([ - "id", "disable", - "enable", "email", + "enable", + "id", "lastLoginAt", "roles", "signedUpAt", diff --git a/packages/user/src/model/users/graphql/resolver.ts b/packages/user/src/model/users/graphql/resolver.ts index 442667034..452d19ff8 100644 --- a/packages/user/src/model/users/graphql/resolver.ts +++ b/packages/user/src/model/users/graphql/resolver.ts @@ -1,3 +1,6 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { MercuriusContext } from "mercurius"; + import { GraphQLUpload, Multipart } from "@prefabs.tech/fastify-s3"; import { mercurius } from "mercurius"; import EmailVerification, { @@ -11,6 +14,8 @@ import { } from "supertokens-node/recipe/thirdpartyemailpassword"; import UserRoles from "supertokens-node/recipe/userroles"; +import type { UserUpdateInput } from "../../../types"; + import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; import CustomApiError from "../../../customApiError"; import getUserService from "../../../lib/getUserService"; @@ -20,10 +25,6 @@ import validateEmail from "../../../validator/email"; import validatePassword from "../../../validator/password"; import filterUserUpdateInput from "../filterUserUpdateInput"; -import type { UserUpdateInput } from "../../../types"; -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; -import type { MercuriusContext } from "mercurius"; - const Mutation = { adminSignUp: async ( parent: unknown, @@ -89,16 +90,16 @@ const Mutation = { // signup const signUpResponse = await emailPasswordSignUp(email, password, { - autoVerifyEmail: true, - roles: [ - ROLE_ADMIN, - ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), - ], _default: { request: { request: reply.request, }, }, + autoVerifyEmail: true, + roles: [ + ROLE_ADMIN, + ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), + ], }); if (signUpResponse.status !== "OK") { @@ -126,6 +127,162 @@ const Mutation = { return mercuriusError; } }, + changeEmail: async ( + parent: unknown, + arguments_: { + email: string; + }, + context: MercuriusContext, + ) => { + const { app, config, database, dbSchema, reply, user } = context; + + try { + if (user) { + if (config.user.features?.updateEmail?.enabled === false) { + return new mercurius.ErrorWithProps("EMAIL_FEATURE_DISABLED_ERROR"); + } + + const request = reply.request; + + if (config.user.features?.profileValidation?.enabled) { + await request.session?.fetchAndSetClaim( + new ProfileValidationClaim(), + createUserContext(undefined, request), + ); + } + + if (config.user.features?.signUp?.emailVerification) { + await request.session?.fetchAndSetClaim( + EmailVerificationClaim, + createUserContext(undefined, request), + ); + } + + const emailValidationResult = validateEmail(arguments_.email, config); + + if (!emailValidationResult.success) { + return new mercurius.ErrorWithProps("EMAIL_INVALID_ERROR"); + } + + if (user.email === arguments_.email) { + return new mercurius.ErrorWithProps("EMAIL_SAME_AS_CURRENT_ERROR"); + } + + if (config.user.features?.signUp?.emailVerification) { + const isVerified = await isEmailVerified(user.id, arguments_.email); + + if (!isVerified) { + const users = await getUsersByEmail(arguments_.email); + + const emailPasswordRecipeUsers = users.filter( + (user) => !user.thirdParty, + ); + + if (emailPasswordRecipeUsers.length > 0) { + return new mercurius.ErrorWithProps("EMAIL_ALREADY_EXISTS_ERROR"); + } + + const tokenResponse = + await EmailVerification.createEmailVerificationToken( + user.id, + arguments_.email, + ); + + if (tokenResponse.status === "OK") { + await EmailVerification.sendEmail({ + emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, + type: "EMAIL_VERIFICATION", + user: { + email: arguments_.email, + id: user.id, + }, + userContext: { + _default: { + request: { + request: request, + }, + }, + }, + }); + + return { + message: "A verification link has been sent to your email.", + status: "OK", + }; + } + + return new mercurius.ErrorWithProps(tokenResponse.status); + } + } + + const service = getUserService(config, database, dbSchema); + + const response = await service.changeEmail(user.id, arguments_.email); + + request.user = response; + + return { + message: "Email updated successfully.", + status: "OK", + }; + } else { + return new mercurius.ErrorWithProps("USER_NOT_FOUND"); + } + /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + app.log.error(error); + + if (error.message === "EMAIL_ALREADY_EXISTS_ERROR") { + return new mercurius.ErrorWithProps(error.message); + } + + return new mercurius.ErrorWithProps( + "Oops, Something went wrong", + {}, + 500, + ); + } + }, + changePassword: async ( + parent: unknown, + arguments_: { + newPassword: string; + oldPassword: string; + }, + context: MercuriusContext, + ) => { + const { app, config, database, dbSchema, reply, user } = context; + + const service = getUserService(config, database, dbSchema); + + if (!user) { + return new mercurius.ErrorWithProps("unauthorized", {}, 401); + } + + try { + const response = await service.changePassword( + user.id, + arguments_.oldPassword, + arguments_.newPassword, + ); + + if (response.status === "OK") { + await createNewSession(reply.request, reply, user.id); + } + + return response; + } catch (error) { + // FIXME [OP 28 SEP 2022] + app.log.error(error); + + const mercuriusError = new mercurius.ErrorWithProps( + "Oops, Something went wrong", + ); + mercuriusError.statusCode = 500; + + return mercuriusError; + } + }, deleteMe: async ( parent: unknown, arguments_: { @@ -223,12 +380,9 @@ const Mutation = { return { status: "OK" }; }, - changePassword: async ( + removePhoto: async ( parent: unknown, - arguments_: { - oldPassword: string; - newPassword: string; - }, + arguments_: undefined, context: MercuriusContext, ) => { const { app, config, database, dbSchema, reply, user } = context; @@ -240,19 +394,33 @@ const Mutation = { } try { - const response = await service.changePassword( - user.id, - arguments_.oldPassword, - arguments_.newPassword, - ); + // eslint-disable-next-line unicorn/no-null + const updatedUser = await service.update(user.id, { photoId: null }); - if (response.status === "OK") { - await createNewSession(reply.request, reply, user.id); + if (user.photoId) { + await service.fileService.delete(user.photoId); } - return response; + const request = reply.request; + + request.user = updatedUser; + + if (request.config.user.features?.profileValidation?.enabled) { + await request.session?.fetchAndSetClaim( + new ProfileValidationClaim(), + createUserContext(undefined, request), + ); + } + + if (request.config.user.features?.signUp?.emailVerification) { + await request.session?.fetchAndSetClaim( + EmailVerificationClaim, + createUserContext(undefined, request), + ); + } + + return updatedUser; } catch (error) { - // FIXME [OP 28 SEP 2022] app.log.error(error); const mercuriusError = new mercurius.ErrorWithProps( @@ -391,173 +559,6 @@ const Mutation = { return mercuriusError; } }, - removePhoto: async ( - parent: unknown, - arguments_: undefined, - context: MercuriusContext, - ) => { - const { app, config, database, dbSchema, reply, user } = context; - - const service = getUserService(config, database, dbSchema); - - if (!user) { - return new mercurius.ErrorWithProps("unauthorized", {}, 401); - } - - try { - // eslint-disable-next-line unicorn/no-null - const updatedUser = await service.update(user.id, { photoId: null }); - - if (user.photoId) { - await service.fileService.delete(user.photoId); - } - - const request = reply.request; - - request.user = updatedUser; - - if (request.config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), - ); - } - - if (request.config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), - ); - } - - return updatedUser; - } catch (error) { - app.log.error(error); - - const mercuriusError = new mercurius.ErrorWithProps( - "Oops, Something went wrong", - ); - mercuriusError.statusCode = 500; - - return mercuriusError; - } - }, - changeEmail: async ( - parent: unknown, - arguments_: { - email: string; - }, - context: MercuriusContext, - ) => { - const { app, config, database, dbSchema, user, reply } = context; - - try { - if (user) { - if (config.user.features?.updateEmail?.enabled === false) { - return new mercurius.ErrorWithProps("EMAIL_FEATURE_DISABLED_ERROR"); - } - - const request = reply.request; - - if (config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), - ); - } - - if (config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), - ); - } - - const emailValidationResult = validateEmail(arguments_.email, config); - - if (!emailValidationResult.success) { - return new mercurius.ErrorWithProps("EMAIL_INVALID_ERROR"); - } - - if (user.email === arguments_.email) { - return new mercurius.ErrorWithProps("EMAIL_SAME_AS_CURRENT_ERROR"); - } - - if (config.user.features?.signUp?.emailVerification) { - const isVerified = await isEmailVerified(user.id, arguments_.email); - - if (!isVerified) { - const users = await getUsersByEmail(arguments_.email); - - const emailPasswordRecipeUsers = users.filter( - (user) => !user.thirdParty, - ); - - if (emailPasswordRecipeUsers.length > 0) { - return new mercurius.ErrorWithProps("EMAIL_ALREADY_EXISTS_ERROR"); - } - - const tokenResponse = - await EmailVerification.createEmailVerificationToken( - user.id, - arguments_.email, - ); - - if (tokenResponse.status === "OK") { - await EmailVerification.sendEmail({ - type: "EMAIL_VERIFICATION", - user: { - id: user.id, - email: arguments_.email, - }, - emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, - userContext: { - _default: { - request: { - request: request, - }, - }, - }, - }); - - return { - status: "OK", - message: "A verification link has been sent to your email.", - }; - } - - return new mercurius.ErrorWithProps(tokenResponse.status); - } - } - - const service = getUserService(config, database, dbSchema); - - const response = await service.changeEmail(user.id, arguments_.email); - - request.user = response; - - return { - status: "OK", - message: "Email updated successfully.", - }; - } else { - return new mercurius.ErrorWithProps("USER_NOT_FOUND"); - } - /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ - } catch (error: any) { - app.log.error(error); - - if (error.message === "EMAIL_ALREADY_EXISTS_ERROR") { - return new mercurius.ErrorWithProps(error.message); - } - - return new mercurius.ErrorWithProps( - "Oops, Something went wrong", - {}, - 500, - ); - } - }, }; const Query = { @@ -649,9 +650,9 @@ const Query = { users: async ( parent: unknown, arguments_: { + filters?: FilterInput; limit: number; offset: number; - filters?: FilterInput; sort?: SortInput[]; }, context: MercuriusContext, diff --git a/packages/user/src/model/users/handlers/adminSignUp.ts b/packages/user/src/model/users/handlers/adminSignUp.ts index 054e795b8..93f568507 100644 --- a/packages/user/src/model/users/handlers/adminSignUp.ts +++ b/packages/user/src/model/users/handlers/adminSignUp.ts @@ -1,3 +1,5 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + import { createNewSession } from "supertokens-node/recipe/session"; import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; import UserRoles from "supertokens-node/recipe/userroles"; @@ -6,8 +8,6 @@ import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; import validateEmail from "../../../validator/email"; import validatePassword from "../../../validator/password"; -import type { FastifyReply, FastifyRequest } from "fastify"; - interface FieldInput { email: string; password: string; @@ -56,16 +56,16 @@ const adminSignUp = async (request: FastifyRequest, reply: FastifyReply) => { // signup const signUpResponse = await emailPasswordSignUp(email, password, { - autoVerifyEmail: true, - roles: [ - ROLE_ADMIN, - ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), - ], _default: { request: { request, }, }, + autoVerifyEmail: true, + roles: [ + ROLE_ADMIN, + ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), + ], }); if (signUpResponse.status !== "OK") { diff --git a/packages/user/src/model/users/handlers/canAdminSignUp.ts b/packages/user/src/model/users/handlers/canAdminSignUp.ts index 9380b56ab..dc9993a16 100644 --- a/packages/user/src/model/users/handlers/canAdminSignUp.ts +++ b/packages/user/src/model/users/handlers/canAdminSignUp.ts @@ -1,9 +1,9 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; + import UserRoles from "supertokens-node/recipe/userroles"; import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; -import type { FastifyReply, FastifyRequest } from "fastify"; - const canAdminSignUp = async (request: FastifyRequest, reply: FastifyReply) => { const { server } = request; diff --git a/packages/user/src/model/users/handlers/changeEmail.ts b/packages/user/src/model/users/handlers/changeEmail.ts index 280c095c8..d3926fb11 100644 --- a/packages/user/src/model/users/handlers/changeEmail.ts +++ b/packages/user/src/model/users/handlers/changeEmail.ts @@ -1,3 +1,5 @@ +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { FastifyReply } from "fastify"; import EmailVerification, { EmailVerificationClaim, @@ -5,16 +7,15 @@ import EmailVerification, { } from "supertokens-node/recipe/emailverification"; import { getUsersByEmail } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { ChangeEmailInput } from "../../../types"; + import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; import validateEmail from "../../../validator/email"; -import type { ChangeEmailInput } from "../../../types"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { - const { body, config, user, server, slonik, session } = request; + const { body, config, server, session, slonik, user } = request; if (!user) { throw server.httpErrors.unauthorized("Unauthorised"); @@ -53,8 +54,8 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { if (user.email === email) { return reply.send({ - status: "EMAIL_SAME_AS_CURRENT_ERROR", message: "Email is same as the current one.", + status: "EMAIL_SAME_AS_CURRENT_ERROR", }); } @@ -79,12 +80,12 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { if (tokenResponse.status === "OK") { await EmailVerification.sendEmail({ + emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, type: "EMAIL_VERIFICATION", user: { - id: user.id, email: email, + id: user.id, }, - emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, userContext: { _default: { request: { @@ -95,8 +96,8 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { }); return reply.send({ - status: "OK", message: "A verification link has been sent to your email.", + status: "OK", }); } @@ -110,7 +111,7 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { request.user = response; - return reply.send({ status: "OK", message: "Email updated successfully." }); + return reply.send({ message: "Email updated successfully.", status: "OK" }); /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ } catch (error: any) { if (error.message === "EMAIL_ALREADY_EXISTS_ERROR") { diff --git a/packages/user/src/model/users/handlers/changePassword.ts b/packages/user/src/model/users/handlers/changePassword.ts index 5a923a1b3..98bff47b0 100644 --- a/packages/user/src/model/users/handlers/changePassword.ts +++ b/packages/user/src/model/users/handlers/changePassword.ts @@ -1,12 +1,13 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { createNewSession } from "supertokens-node/recipe/session"; +import type { ChangePasswordInput } from "../../../types"; + import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; -import type { ChangePasswordInput } from "../../../types"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const changePassword = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request; diff --git a/packages/user/src/model/users/handlers/deleteMe.ts b/packages/user/src/model/users/handlers/deleteMe.ts index 56806dd5b..f6915fdfe 100644 --- a/packages/user/src/model/users/handlers/deleteMe.ts +++ b/packages/user/src/model/users/handlers/deleteMe.ts @@ -1,10 +1,10 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import getUserService from "../../../lib/getUserService"; -import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const deleteMe = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ diff --git a/packages/user/src/model/users/handlers/disable.ts b/packages/user/src/model/users/handlers/disable.ts index 43d3b4b6d..910780ef7 100644 --- a/packages/user/src/model/users/handlers/disable.ts +++ b/packages/user/src/model/users/handlers/disable.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../lib/getUserService"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import getUserService from "../../../lib/getUserService"; + const disable = async (request: SessionRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; diff --git a/packages/user/src/model/users/handlers/enable.ts b/packages/user/src/model/users/handlers/enable.ts index 758632462..e3cbc4587 100644 --- a/packages/user/src/model/users/handlers/enable.ts +++ b/packages/user/src/model/users/handlers/enable.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../lib/getUserService"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import getUserService from "../../../lib/getUserService"; + const enable = async (request: SessionRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; diff --git a/packages/user/src/model/users/handlers/me.ts b/packages/user/src/model/users/handlers/me.ts index 8d9fa16af..0c131af9f 100644 --- a/packages/user/src/model/users/handlers/me.ts +++ b/packages/user/src/model/users/handlers/me.ts @@ -1,12 +1,12 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const me = async (request: SessionRequest, reply: FastifyReply) => { const { config, server, session, user } = request; diff --git a/packages/user/src/model/users/handlers/removePhoto.ts b/packages/user/src/model/users/handlers/removePhoto.ts index 4f5bf2e61..c00496aba 100644 --- a/packages/user/src/model/users/handlers/removePhoto.ts +++ b/packages/user/src/model/users/handlers/removePhoto.ts @@ -1,3 +1,6 @@ +import type { FastifyReply } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; @@ -5,9 +8,6 @@ import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const removePhoto = async (request: SessionRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; diff --git a/packages/user/src/model/users/handlers/updateMe.ts b/packages/user/src/model/users/handlers/updateMe.ts index 0254c3ca0..f1fb4f227 100644 --- a/packages/user/src/model/users/handlers/updateMe.ts +++ b/packages/user/src/model/users/handlers/updateMe.ts @@ -1,18 +1,19 @@ +import type { File } from "@prefabs.tech/fastify-s3"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { UserUpdateInput } from "../../../types"; + import { ERROR_CODES } from "../../../constants"; import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; import filterUserUpdateInput from "../filterUserUpdateInput"; -import type { UserUpdateInput } from "../../../types"; -import type { File } from "@prefabs.tech/fastify-s3"; -import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const updateMe = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ diff --git a/packages/user/src/model/users/handlers/uploadPhoto.ts b/packages/user/src/model/users/handlers/uploadPhoto.ts index 07878fbbd..fbfd2384e 100644 --- a/packages/user/src/model/users/handlers/uploadPhoto.ts +++ b/packages/user/src/model/users/handlers/uploadPhoto.ts @@ -1,17 +1,18 @@ +import type { Multipart } from "@prefabs.tech/fastify-s3"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { SessionRequest } from "supertokens-node/framework/fastify"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { UserUpdateInput } from "../../../types"; + import { ERROR_CODES } from "../../../constants"; import getUserService from "../../../lib/getUserService"; import createUserContext from "../../../supertokens/utils/createUserContext"; import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; -import type { UserUpdateInput } from "../../../types"; -import type { Multipart } from "@prefabs.tech/fastify-s3"; -import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - const uploadPhoto = async (request: SessionRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ diff --git a/packages/user/src/model/users/handlers/user.ts b/packages/user/src/model/users/handlers/user.ts index d8d623844..0de2764e4 100644 --- a/packages/user/src/model/users/handlers/user.ts +++ b/packages/user/src/model/users/handlers/user.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../lib/getUserService"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import getUserService from "../../../lib/getUserService"; + const user = async (request: SessionRequest, reply: FastifyReply) => { const service = getUserService( request.config, diff --git a/packages/user/src/model/users/handlers/users.ts b/packages/user/src/model/users/handlers/users.ts index 782d45b54..0a28a042b 100644 --- a/packages/user/src/model/users/handlers/users.ts +++ b/packages/user/src/model/users/handlers/users.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../lib/getUserService"; - import type { FastifyReply } from "fastify"; import type { SessionRequest } from "supertokens-node/framework/fastify"; +import getUserService from "../../../lib/getUserService"; + const users = async (request: SessionRequest, reply: FastifyReply) => { const service = getUserService( request.config, @@ -10,10 +10,10 @@ const users = async (request: SessionRequest, reply: FastifyReply) => { request.dbSchema, ); - const { limit, offset, filters, sort } = request.query as { + const { filters, limit, offset, sort } = request.query as { + filters?: string; limit: number; offset?: number; - filters?: string; sort?: string; }; diff --git a/packages/user/src/model/users/schema.ts b/packages/user/src/model/users/schema.ts index 0c41f2d73..2ec22583c 100644 --- a/packages/user/src/model/users/schema.ts +++ b/packages/user/src/model/users/schema.ts @@ -1,41 +1,41 @@ const userSchema = { - type: "object", + additionalProperties: true, properties: { - id: { type: "string" }, - email: { type: "string", format: "email" }, - roles: { type: "array", items: { type: "string" } }, + deletedAt: { nullable: true, type: "number" }, disabled: { type: "boolean" }, + email: { format: "email", type: "string" }, + id: { type: "string" }, lastLoginAt: { type: "number" }, + photoId: { nullable: true, type: "number" }, + roles: { items: { type: "string" }, type: "array" }, signedUpAt: { type: "number" }, - deletedAt: { type: "number", nullable: true }, - photoId: { type: "number", nullable: true }, }, - additionalProperties: true, required: ["id", "email", "roles", "disabled", "lastLoginAt", "signedUpAt"], + type: "object", }; export const adminSignUpSchema = { - description: "Create a new admin user", - operationId: "adminSignUp", body: { - type: "object", - required: ["email", "password"], properties: { - email: { type: "string", format: "email" }, - password: { type: "string", format: "password" }, + email: { format: "email", type: "string" }, + password: { format: "password", type: "string" }, }, + required: ["email", "password"], + type: "object", }, + description: "Create a new admin user", + operationId: "adminSignUp", response: { 200: { - type: "object", properties: { status: { type: "string" }, user: userSchema, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 500: { $ref: "ErrorResponse#", @@ -49,10 +49,10 @@ export const canAdminSignUpSchema = { operationId: "canAdminSignUp", response: { 200: { - type: "object", properties: { signUp: { type: "boolean" }, }, + type: "object", }, 500: { $ref: "ErrorResponse#", @@ -62,30 +62,30 @@ export const canAdminSignUpSchema = { }; export const changeEmailSchema = { - description: "Change user's email address", - operationId: "changeEmail", body: { - type: "object", - required: ["email"], properties: { - email: { type: "string", format: "email" }, + email: { format: "email", type: "string" }, }, + required: ["email"], + type: "object", }, + description: "Change user's email address", + operationId: "changeEmail", response: { 200: { - type: "object", properties: { - status: { type: "string" }, message: { type: "string" }, + status: { type: "string" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -95,32 +95,32 @@ export const changeEmailSchema = { }; export const changePasswordSchema = { - description: "Change user's password", - operationId: "changePassword", body: { - type: "object", - required: ["oldPassword", "newPassword"], properties: { - oldPassword: { type: "string", format: "password" }, - newPassword: { type: "string", format: "password" }, + newPassword: { format: "password", type: "string" }, + oldPassword: { format: "password", type: "string" }, }, + required: ["oldPassword", "newPassword"], + type: "object", }, + description: "Change user's password", + operationId: "changePassword", response: { 200: { - type: "object", properties: { - statusCode: { type: "number" }, - status: { type: "string" }, message: { type: "string" }, + status: { type: "string" }, + statusCode: { type: "number" }, }, + type: "object", }, 400: { - description: "Bad Request", $ref: "ErrorResponse#", + description: "Bad Request", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -130,26 +130,26 @@ export const changePasswordSchema = { }; export const deleteMeSchema = { - description: "Delete the current user's account", - operationId: "deleteMe", body: { - type: "object", - required: ["password"], properties: { - password: { type: "string", format: "password" }, + password: { format: "password", type: "string" }, }, + required: ["password"], + type: "object", }, + description: "Delete the current user's account", + operationId: "deleteMe", response: { 200: { description: "User deleted successfully", - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -159,19 +159,19 @@ export const deleteMeSchema = { }; export const uploadPhotoSchema = { - description: "Upload a photo for the current user", - consumes: ["multipart/form-data"], body: { - type: "object", properties: { photo: { isFile: true }, }, + type: "object", }, + consumes: ["multipart/form-data"], + description: "Upload a photo for the current user", response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -186,8 +186,8 @@ export const removePhotoSchema = { response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -200,30 +200,30 @@ export const disableUserSchema = { description: "Disable a user account", operationId: "disableUser", params: { - type: "object", - required: ["id"], properties: { id: { type: "string" }, }, + required: ["id"], + type: "object", }, response: { 200: { - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "User not found", $ref: "ErrorResponse#", + description: "User not found", }, 500: { $ref: "ErrorResponse#", @@ -236,30 +236,30 @@ export const enableUserSchema = { description: "Enable a user account", operationId: "enableUser", params: { - type: "object", - required: ["id"], properties: { id: { type: "string" }, }, + required: ["id"], + type: "object", }, response: { 200: { - type: "object", properties: { status: { type: "string" }, }, + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "User not found", $ref: "ErrorResponse#", + description: "User not found", }, 500: { $ref: "ErrorResponse#", @@ -274,8 +274,8 @@ export const getMeSchema = { response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", @@ -288,25 +288,25 @@ export const getUserSchema = { description: "Get a user by ID", operationId: "getUser", params: { - type: "object", - required: ["id"], properties: { id: { type: "string" }, }, + required: ["id"], + type: "object", }, response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 404: { - description: "User not found", $ref: "ErrorResponse#", + description: "User not found", }, 500: { $ref: "ErrorResponse#", @@ -320,34 +320,34 @@ export const getUsersSchema = { "Get a paginated list of users with optional filtering and sorting", operationId: "getUsers", querystring: { - type: "object", properties: { + filters: { type: "string" }, limit: { type: "number" }, offset: { type: "number" }, - filters: { type: "string" }, sort: { type: "string" }, }, + type: "object", }, response: { 200: { - type: "object", - required: ["totalCount", "filteredCount", "data"], properties: { - totalCount: { type: "integer" }, - filteredCount: { type: "integer" }, data: { - type: "array", items: userSchema, + type: "array", }, + filteredCount: { type: "integer" }, + totalCount: { type: "integer" }, }, + required: ["totalCount", "filteredCount", "data"], + type: "object", }, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 403: { - description: "Forbidden", $ref: "ErrorResponse#", + description: "Forbidden", }, 500: { $ref: "ErrorResponse#", @@ -357,17 +357,17 @@ export const getUsersSchema = { }; export const updateMeSchema = { - description: "Update current user's profile", - operationId: "updateMe", body: { - type: "object", additionalProperties: true, + type: "object", }, + description: "Update current user's profile", + operationId: "updateMe", response: { 200: userSchema, 401: { - description: "Unauthorized", $ref: "ErrorResponse#", + description: "Unauthorized", }, 500: { $ref: "ErrorResponse#", diff --git a/packages/user/src/model/users/service.ts b/packages/user/src/model/users/service.ts index a20ff081d..d2ce8765b 100644 --- a/packages/user/src/model/users/service.ts +++ b/packages/user/src/model/users/service.ts @@ -4,30 +4,54 @@ import { BaseService } from "@prefabs.tech/fastify-slonik"; import Session from "supertokens-node/recipe/session"; import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; -import UserSqlFactory from "./sqlFactory"; +import type { User, UserCreateInput, UserUpdateInput } from "../../types"; + import { DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB, ERROR_CODES, } from "../../constants"; import validatePassword from "../../validator/password"; - -import type { User, UserCreateInput, UserUpdateInput } from "../../types"; +import UserSqlFactory from "./sqlFactory"; class UserService extends BaseService { - protected photoPath = "photo"; - protected photoFilename = "photo"; + get bucket(): string | undefined { + return this.config.user.s3?.bucket; + } + get factory(): UserSqlFactory { + return super.factory as UserSqlFactory; + } + + get fileService() { + if (!this._fileService) { + this._fileService = new FileService( + this.config, + this.database, + this.schema, + ); + } + + return this._fileService; + } + get sqlFactoryClass() { + return UserSqlFactory; + } protected _fileService: FileService | undefined; + protected _supportedMimeTypes: string[] = [ "image/jpeg", "image/png", "image/webp", ]; + protected photoFilename = "photo"; + + protected photoPath = "photo"; + async changeEmail(id: string, email: string) { const response = await ThirdPartyEmailPassword.updateEmailOrPassword({ - userId: id, email: email, + userId: id, }); if (response.status !== "OK") { @@ -52,8 +76,8 @@ class UserService extends BaseService { if (!passwordValidation.success) { return { - status: "FIELD_ERROR", message: passwordValidation.message, + status: "FIELD_ERROR", }; } @@ -70,8 +94,8 @@ class UserService extends BaseService { if (isPasswordValid.status === "OK") { const result = await ThirdPartyEmailPassword.updateEmailOrPassword({ - userId, password: newPassword, + userId, }); if (result) { @@ -88,8 +112,8 @@ class UserService extends BaseService { } } else { return { - status: "INVALID_PASSWORD", message: "Invalid password", + status: "INVALID_PASSWORD", }; } } else { @@ -97,12 +121,28 @@ class UserService extends BaseService { } } else { return { - status: "FIELD_ERROR", message: "Password cannot be empty", + status: "FIELD_ERROR", }; } } + async deleteFile(fileId: number): Promise { + if (!this.bucket) { + console.warn( + "S3 bucket for user model is not configured. Skipping file delete.", + ); + + return undefined; + } + + const result = await this.fileService.deleteFile(fileId, { + bucket: this.bucket, + }); + + return result; + } + async deleteMe(userId: string, password: string) { const user = await ThirdPartyEmailPassword.getUserById(userId); @@ -127,22 +167,6 @@ class UserService extends BaseService { } } - async deleteFile(fileId: number): Promise { - if (!this.bucket) { - console.warn( - "S3 bucket for user model is not configured. Skipping file delete.", - ); - - return undefined; - } - - const result = await this.fileService.deleteFile(fileId, { - bucket: this.bucket, - }); - - return result; - } - async uploadPhoto( photo: Multipart, userId: string, @@ -155,36 +179,6 @@ class UserService extends BaseService { return this.upload(photo, path, filename, uploadedById, uploadedAt); } - get bucket(): string | undefined { - return this.config.user.s3?.bucket; - } - - get factory(): UserSqlFactory { - return super.factory as UserSqlFactory; - } - - get fileService() { - if (!this._fileService) { - this._fileService = new FileService( - this.config, - this.database, - this.schema, - ); - } - - return this._fileService; - } - - get sqlFactoryClass() { - return UserSqlFactory; - } - - protected async postDelete(result: User): Promise { - await Session.revokeAllSessionsForUser(result.id); - - return result; - } - protected getPhotoPath(userId: string): string { return `${userId}/${this.photoPath}`; } @@ -204,6 +198,12 @@ class UserService extends BaseService { return user; } + protected async postDelete(result: User): Promise { + await Session.revokeAllSessionsForUser(result.id); + + return result; + } + protected async postFindById(result: User): Promise { return await this.getUserWithPhoto(result); } @@ -258,9 +258,9 @@ class UserService extends BaseService { file: { fileContent: data, fileFields: { - uploadedById: uploadedById, - uploadedAt: uploadedAt || Date.now(), bucket: this.bucket, + uploadedAt: uploadedAt || Date.now(), + uploadedById: uploadedById, }, }, options: { diff --git a/packages/user/src/model/users/sql.ts b/packages/user/src/model/users/sql.ts index c5ad5ad8b..500d71633 100644 --- a/packages/user/src/model/users/sql.ts +++ b/packages/user/src/model/users/sql.ts @@ -1,11 +1,11 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { FragmentSqlToken, IdentifierSqlToken } from "slonik"; + import humps from "humps"; import { sql } from "slonik"; import { applyFiltersToQuery } from "./dbFilters"; -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; -import type { FragmentSqlToken, IdentifierSqlToken } from "slonik"; - const createRoleSortFragment = ( identifier: IdentifierSqlToken, sort?: SortInput[], diff --git a/packages/user/src/model/users/sqlFactory.ts b/packages/user/src/model/users/sqlFactory.ts index 4dedca333..45a7c215d 100644 --- a/packages/user/src/model/users/sqlFactory.ts +++ b/packages/user/src/model/users/sqlFactory.ts @@ -1,21 +1,25 @@ +import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; + import { DefaultSqlFactory } from "@prefabs.tech/fastify-slonik"; import humps from "humps"; import { FragmentSqlToken, QuerySqlToken, sql } from "slonik"; import { z } from "zod"; +import { TABLE_USERS } from "../../constants"; +import { ChangeEmailInput, UserUpdateInput } from "../../types"; import { createRoleSortFragment, createUserFilterFragment, createUserSortFragment, } from "./sql"; -import { TABLE_USERS } from "../../constants"; -import { ChangeEmailInput, UserUpdateInput } from "../../types"; - -import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; class UserSqlFactory extends DefaultSqlFactory { static readonly TABLE = TABLE_USERS; + get table() { + return this.config.user?.tables?.users?.name || super.table; + } + protected _softDeleteEnabled: boolean = true; getCountSql(filters?: FilterInput): QuerySqlToken { @@ -79,7 +83,7 @@ class UserSqlFactory extends DefaultSqlFactory { getUpdateSql( id: number | string, - data: UserUpdateInput | ChangeEmailInput, + data: ChangeEmailInput | UserUpdateInput, ): QuerySqlToken { const columns = []; @@ -109,10 +113,6 @@ class UserSqlFactory extends DefaultSqlFactory { `; } - get table() { - return this.config.user?.tables?.users?.name || super.table; - } - protected getFilterFragment(filters?: FilterInput): FragmentSqlToken { return createUserFilterFragment(filters, this.tableIdentifier); } diff --git a/packages/user/src/plugin.ts b/packages/user/src/plugin.ts index ca3623d27..b61d94b76 100644 --- a/packages/user/src/plugin.ts +++ b/packages/user/src/plugin.ts @@ -1,3 +1,6 @@ +import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; +import type { FastifyPluginAsync } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import seedRoles from "./lib/seedRoles"; @@ -11,9 +14,6 @@ import usersRoutes from "./model/users/controller"; import supertokensPlugin from "./supertokens"; import userContext from "./userContext"; -import type { GraphqlEnabledPlugin } from "@prefabs.tech/fastify-graphql"; -import type { FastifyPluginAsync } from "fastify"; - const userPlugin: FastifyPluginAsync = async (fastify) => { const { graphql, user } = fastify.config; diff --git a/packages/user/src/schemas/password.ts b/packages/user/src/schemas/password.ts index a609a4660..f95380cf0 100644 --- a/packages/user/src/schemas/password.ts +++ b/packages/user/src/schemas/password.ts @@ -6,16 +6,16 @@ import type { PasswordErrorMessages, StrongPasswordOptions } from "../types"; const defaultOptions = { minLength: 8, minLowercase: 0, - minUppercase: 0, minNumbers: 0, minSymbols: 0, - returnScore: false, - pointsPerUnique: 1, - pointsPerRepeat: 0.5, + minUppercase: 0, pointsForContainingLower: 10, - pointsForContainingUpper: 10, pointsForContainingNumber: 10, pointsForContainingSymbol: 10, + pointsForContainingUpper: 10, + pointsPerRepeat: 0.5, + pointsPerUnique: 1, + returnScore: false, }; const schema = ( diff --git a/packages/user/src/supertokens/init.ts b/packages/user/src/supertokens/init.ts index 15a34cf4f..05fb5b751 100644 --- a/packages/user/src/supertokens/init.ts +++ b/packages/user/src/supertokens/init.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance } from "fastify"; + import supertokens from "supertokens-node"; import getRecipeList from "./recipes"; -import type { FastifyInstance } from "fastify"; - const init = (fastify: FastifyInstance) => { const { config } = fastify; diff --git a/packages/user/src/supertokens/plugin.ts b/packages/user/src/supertokens/plugin.ts index 2458a059b..291068e05 100644 --- a/packages/user/src/supertokens/plugin.ts +++ b/packages/user/src/supertokens/plugin.ts @@ -1,3 +1,5 @@ +import type { FastifyInstance } from "fastify"; + import FastifyPlugin from "fastify-plugin"; import { plugin as supertokensPlugin } from "supertokens-node/framework/fastify"; import { verifySession } from "supertokens-node/recipe/session/framework/fastify"; @@ -5,8 +7,6 @@ import { verifySession } from "supertokens-node/recipe/session/framework/fastify import { errorHandler } from "./errorHandler"; import init from "./init"; -import type { FastifyInstance } from "fastify"; - const plugin = async (fastify: FastifyInstance) => { const { config, log } = fastify; diff --git a/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts b/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts index b32b3424b..b500b9d0e 100644 --- a/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts +++ b/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts @@ -1,13 +1,13 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; +import type { TypeEmailVerificationEmailDeliveryInput } from "supertokens-node/recipe/emailverification/types"; + import emailVerification from "supertokens-node/recipe/emailverification"; import { EMAIL_VERIFICATION_PATH } from "../../../../constants"; import getOrigin from "../../../../lib/getOrigin"; import sendEmail from "../../../../lib/sendEmail"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; -import type { TypeEmailVerificationEmailDeliveryInput } from "supertokens-node/recipe/emailverification/types"; - const sendEmailVerificationEmail = ( originalImplementation: EmailDeliveryInterface, fastify: FastifyInstance, @@ -41,14 +41,14 @@ const sendEmailVerificationEmail = ( subject: fastify.config.user.emailOverrides?.emailVerification?.subject || "Email verification", - templateName: - fastify.config.user.emailOverrides?.emailVerification?.templateName || - "email-verification", - to: input.user.email, templateData: { emailVerifyLink, user: input.user, }, + templateName: + fastify.config.user.emailOverrides?.emailVerification?.templateName || + "email-verification", + to: input.user.email, }); }; }; diff --git a/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts index 9f686c4bf..e2cee9212 100644 --- a/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts @@ -1,18 +1,19 @@ -import sendEmailVerificationEmail from "./email-verification/sendEmailVerificationEmail"; -import { EMAIL_VERIFICATION_MODE } from "../../../constants"; -import getUserService from "../../../lib/getUserService"; - -import type { - SendEmailWrapper, - EmailVerificationRecipe, -} from "../../types/emailVerificationRecipe"; import type { FastifyInstance } from "fastify"; import type { APIInterface, - RecipeInterface, TypeInput as EmailVerificationRecipeConfig, + RecipeInterface, } from "supertokens-node/recipe/emailverification/types"; +import type { + EmailVerificationRecipe, + SendEmailWrapper, +} from "../../types/emailVerificationRecipe"; + +import { EMAIL_VERIFICATION_MODE } from "../../../constants"; +import getUserService from "../../../lib/getUserService"; +import sendEmailVerificationEmail from "./email-verification/sendEmailVerificationEmail"; + const getEmailVerificationRecipeConfig = ( fastify: FastifyInstance, ): EmailVerificationRecipeConfig => { @@ -25,7 +26,6 @@ const getEmailVerificationRecipeConfig = ( } return { - mode: emailVerification?.mode || EMAIL_VERIFICATION_MODE, emailDelivery: { override: (originalImplementation) => { let sendEmailConfig: SendEmailWrapper | undefined; @@ -42,6 +42,7 @@ const getEmailVerificationRecipeConfig = ( }; }, }, + mode: emailVerification?.mode || EMAIL_VERIFICATION_MODE, override: { apis: (originalImplementation) => { const apiInterface: Partial = {}; diff --git a/packages/user/src/supertokens/recipes/config/session/createNewSession.ts b/packages/user/src/supertokens/recipes/config/session/createNewSession.ts index 95c9ae328..a3ebbabc1 100644 --- a/packages/user/src/supertokens/recipes/config/session/createNewSession.ts +++ b/packages/user/src/supertokens/recipes/config/session/createNewSession.ts @@ -1,11 +1,11 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/session/types"; + import { getRequestFromUserContext } from "supertokens-node"; import getUserService from "../../../../lib/getUserService"; import ProfileValidationClaim from "../../../utils/profileValidationClaim"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/session/types"; - const createNewSession = ( originalImplementation: RecipeInterface, diff --git a/packages/user/src/supertokens/recipes/config/session/getGlobalClaimValidators.ts b/packages/user/src/supertokens/recipes/config/session/getGlobalClaimValidators.ts index be3666a5a..a8fc5893f 100644 --- a/packages/user/src/supertokens/recipes/config/session/getGlobalClaimValidators.ts +++ b/packages/user/src/supertokens/recipes/config/session/getGlobalClaimValidators.ts @@ -1,10 +1,10 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/session/types"; + import { getRequestFromUserContext } from "supertokens-node"; import ProfileValidationClaim from "../../../utils/profileValidationClaim"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/session/types"; - const getGlobalClaimValidators = ( originalImplementation: RecipeInterface, // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/user/src/supertokens/recipes/config/session/getSession.ts b/packages/user/src/supertokens/recipes/config/session/getSession.ts index e5b1def20..874a0869b 100644 --- a/packages/user/src/supertokens/recipes/config/session/getSession.ts +++ b/packages/user/src/supertokens/recipes/config/session/getSession.ts @@ -1,8 +1,8 @@ -import getUserService from "../../../../lib/getUserService"; - import type { FastifyInstance, FastifyRequest } from "fastify"; import type { RecipeInterface } from "supertokens-node/recipe/session/types"; +import getUserService from "../../../../lib/getUserService"; + const getSession = ( originalImplementation: RecipeInterface, // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts index 0336391d8..be9245a9c 100644 --- a/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts @@ -1,9 +1,3 @@ -import createNewSession from "./session/createNewSession"; -import getGlobalClaimValidators from "./session/getGlobalClaimValidators"; -import getSession from "./session/getSession"; -import verifySession from "./session/verifySession"; - -import type { SessionRecipe } from "../../types/sessionRecipe"; import type { FastifyInstance } from "fastify"; import type { APIInterface, @@ -11,6 +5,13 @@ import type { TypeInput as SessionRecipeConfig, } from "supertokens-node/recipe/session/types"; +import type { SessionRecipe } from "../../types/sessionRecipe"; + +import createNewSession from "./session/createNewSession"; +import getGlobalClaimValidators from "./session/getGlobalClaimValidators"; +import getSession from "./session/getSession"; +import verifySession from "./session/verifySession"; + const getSessionRecipeConfig = ( fastify: FastifyInstance, ): SessionRecipeConfig => { @@ -80,11 +81,11 @@ const getSessionRecipeConfig = ( return { ...originalImplementation, createNewSession: createNewSession(originalImplementation, fastify), - getSession: getSession(originalImplementation, fastify), getGlobalClaimValidators: getGlobalClaimValidators( originalImplementation, fastify, ), + getSession: getSession(originalImplementation, fastify), ...recipeInterface, }; }, diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts index ca322e50e..68d5eb6c3 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts @@ -1,10 +1,11 @@ -import { formatDate } from "@prefabs.tech/fastify-slonik"; +import type { FastifyInstance } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; -import getUserService from "../../../../lib/getUserService"; +import { formatDate } from "@prefabs.tech/fastify-slonik"; import type { AuthUser } from "../../../../types"; -import type { FastifyInstance } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; + +import getUserService from "../../../../lib/getUserService"; const emailPasswordSignIn = ( originalImplementation: RecipeInterface, diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts index acddb2843..77f33cfc1 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts @@ -1,17 +1,18 @@ +import type { FastifyInstance } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { deleteUser } from "supertokens-node"; import EmailVerification from "supertokens-node/recipe/emailverification"; import UserRoles from "supertokens-node/recipe/userroles"; +import type { User } from "../../../../types"; + import getUserService from "../../../../lib/getUserService"; import sendEmail from "../../../../lib/sendEmail"; import verifyEmail from "../../../../lib/verifyEmail"; import areRolesExist from "../../../utils/areRolesExist"; -import type { User } from "../../../../types"; -import type { FastifyInstance } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword"; - const emailPasswordSignUp = ( originalImplementation: RecipeInterface, fastify: FastifyInstance, @@ -34,12 +35,12 @@ const emailPasswordSignUp = ( if (originalResponse.status === "OK") { const userService = getUserService(config, slonik); - let user: User | null | undefined; + let user: null | undefined | User; try { user = await userService.create({ - id: originalResponse.user.id, email: originalResponse.user.email, + id: originalResponse.user.id, }); if (!user) { @@ -85,9 +86,9 @@ const emailPasswordSignUp = ( // [DU 2023-SEP-4] We need to provide all the arguments. // emailVerifyLink is same as what would supertokens create. await EmailVerification.sendEmail({ + emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, type: "EMAIL_VERIFICATION", user: originalResponse.user, - emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, userContext: input.userContext, }); } diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts index 819b16d36..94aeb0559 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUpPost.ts @@ -1,8 +1,8 @@ -import { ROLE_USER } from "../../../../constants"; - import type { FastifyInstance } from "fastify"; import type { APIInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import { ROLE_USER } from "../../../../constants"; + const emailPasswordSignUpPOST = ( originalImplementation: APIInterface, fastify: FastifyInstance, @@ -23,9 +23,9 @@ const emailPasswordSignUpPOST = ( if (originalResponse.status === "OK") { return { + session: originalResponse.session, status: "OK", user: originalResponse.user, - session: originalResponse.session, }; } diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/getFormFields.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/getFormFields.ts index f95fac37a..68a8daf0a 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/getFormFields.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/getFormFields.ts @@ -1,9 +1,9 @@ -import validateEmail from "../../../../validator/email"; -import validatePassword from "../../../../validator/password"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { TypeInputFormField } from "supertokens-node/lib/build/recipe/emailpassword/types"; +import validateEmail from "../../../../validator/email"; +import validatePassword from "../../../../validator/password"; + const getDefaultFormFields = (config: ApiConfig): TypeInputFormField[] => { return [ { diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/resetPasswordUsingToken.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/resetPasswordUsingToken.ts index 8e09cf89c..91ea0ce11 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/resetPasswordUsingToken.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/resetPasswordUsingToken.ts @@ -1,10 +1,10 @@ +import type { FastifyInstance } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; + import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; import sendEmail from "../../../../lib/sendEmail"; -import type { FastifyInstance } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; - const resetPasswordUsingToken = ( originalImplementation: RecipeInterface, fastify: FastifyInstance, @@ -22,13 +22,13 @@ const resetPasswordUsingToken = ( subject: fastify.config.user.emailOverrides?.resetPasswordNotification ?.subject || "Reset password notification", + templateData: { + emailId: user.email, + }, templateName: fastify.config.user.emailOverrides?.resetPasswordNotification ?.templateName || "reset-password-notification", to: user.email, - templateData: { - emailId: user.email, - }, }); } } diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts index aba2778a6..892c6506f 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts @@ -1,14 +1,14 @@ +import type { AppConfig } from "@prefabs.tech/fastify-config"; +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; +import type { TypeEmailPasswordPasswordResetEmailDeliveryInput } from "supertokens-node/lib/build/recipe/emailpassword/types"; + import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; import { RESET_PASSWORD_PATH } from "../../../../constants"; import getOrigin from "../../../../lib/getOrigin"; import sendEmail from "../../../../lib/sendEmail"; -import type { AppConfig } from "@prefabs.tech/fastify-config"; -import type { FastifyInstance, FastifyRequest } from "fastify"; -import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; -import type { TypeEmailPasswordPasswordResetEmailDeliveryInput } from "supertokens-node/lib/build/recipe/emailpassword/types"; - const sendPasswordResetEmail = ( originalImplementation: EmailDeliveryInterface, fastify: FastifyInstance, @@ -48,14 +48,14 @@ const sendPasswordResetEmail = ( subject: fastify.config.user.emailOverrides?.resetPassword?.subject || "Reset password", - templateName: - fastify.config.user.emailOverrides?.resetPassword?.templateName || - "reset-password", - to: input.user.email, templateData: { passwordResetLink, user: input.user, }, + templateName: + fastify.config.user.emailOverrides?.resetPassword?.templateName || + "reset-password", + to: input.user.email, }); }; }; diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUp.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUp.ts index 207f8dcb7..55bd2836c 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUp.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUp.ts @@ -1,16 +1,17 @@ +import type { FastifyInstance } from "fastify"; +import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword"; + import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { formatDate } from "@prefabs.tech/fastify-slonik"; import { deleteUser } from "supertokens-node"; import { getUserByThirdPartyInfo } from "supertokens-node/recipe/thirdpartyemailpassword"; import UserRoles from "supertokens-node/recipe/userroles"; +import type { User } from "../../../../types"; + import getUserService from "../../../../lib/getUserService"; import areRolesExist from "../../../utils/areRolesExist"; -import type { User } from "../../../../types"; -import type { FastifyInstance } from "fastify"; -import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword"; - const thirdPartySignInUp = ( originalImplementation: RecipeInterface, fastify: FastifyInstance, @@ -60,12 +61,12 @@ const thirdPartySignInUp = ( } } - let user: User | null | undefined; + let user: null | undefined | User; try { user = await userService.create({ - id: originalResponse.user.id, email: originalResponse.user.email, + id: originalResponse.user.id, }); if (!user) { diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUpPost.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUpPost.ts index b92e3cc94..35539282c 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUpPost.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/thirdPartySignInUpPost.ts @@ -1,9 +1,9 @@ -import { ROLE_USER } from "../../../../constants"; -import getUserService from "../../../../lib/getUserService"; - import type { FastifyInstance } from "fastify"; import type { APIInterface } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import { ROLE_USER } from "../../../../constants"; +import getUserService from "../../../../lib/getUserService"; + const thirdPartySignInUpPOST = ( originalImplementation: APIInterface, fastify: FastifyInstance, @@ -35,8 +35,8 @@ const thirdPartySignInUpPOST = ( ); return { - status: "GENERAL_ERROR", message: "Something went wrong", + status: "GENERAL_ERROR", }; } diff --git a/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts index 69229eff1..d504068a3 100644 --- a/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts @@ -1,3 +1,15 @@ +import type { FastifyInstance } from "fastify"; +import type { + APIInterface, + RecipeInterface, + TypeInput as ThirdPartyEmailPasswordRecipeConfig, +} from "supertokens-node/recipe/thirdpartyemailpassword/types"; + +import type { + SendEmailWrapper, + ThirdPartyEmailPasswordRecipe, +} from "../../types/thirdPartyEmailPasswordRecipe"; + import appleRedirectHandlerPOST from "./third-party-email-password/appleRedirectHandlerPost"; import emailPasswordSignIn from "./third-party-email-password/emailPasswordSignIn"; import emailPasswordSignUp from "./third-party-email-password/emailPasswordSignUp"; @@ -9,17 +21,6 @@ import thirdPartySignInUp from "./third-party-email-password/thirdPartySignInUp" import thirdPartySignInUpPOST from "./third-party-email-password/thirdPartySignInUpPost"; import getThirdPartyProviders from "./thirdPartyProviders"; -import type { - SendEmailWrapper, - ThirdPartyEmailPasswordRecipe, -} from "../../types/thirdPartyEmailPasswordRecipe"; -import type { FastifyInstance } from "fastify"; -import type { - APIInterface, - RecipeInterface, - TypeInput as ThirdPartyEmailPasswordRecipeConfig, -} from "supertokens-node/recipe/thirdpartyemailpassword/types"; - const getThirdPartyEmailPasswordRecipeConfig = ( fastify: FastifyInstance, ): ThirdPartyEmailPasswordRecipeConfig => { @@ -35,6 +36,22 @@ const getThirdPartyEmailPasswordRecipeConfig = ( } return { + emailDelivery: { + override: (originalImplementation) => { + let sendEmailConfig: SendEmailWrapper | undefined; + + if (thirdPartyEmailPassword?.sendEmail) { + sendEmailConfig = thirdPartyEmailPassword.sendEmail; + } + + return { + ...originalImplementation, + sendEmail: sendEmailConfig + ? sendEmailConfig(originalImplementation, fastify) + : sendPasswordResetEmail(originalImplementation, fastify), + }; + }, + }, override: { apis: (originalImplementation) => { const apiInterface: Partial = {}; @@ -59,15 +76,15 @@ const getThirdPartyEmailPasswordRecipeConfig = ( return { ...originalImplementation, - emailPasswordSignUpPOST: emailPasswordSignUpPOST( + appleRedirectHandlerPOST: appleRedirectHandlerPOST( originalImplementation, fastify, ), - thirdPartySignInUpPOST: thirdPartySignInUpPOST( + emailPasswordSignUpPOST: emailPasswordSignUpPOST( originalImplementation, fastify, ), - appleRedirectHandlerPOST: appleRedirectHandlerPOST( + thirdPartySignInUpPOST: thirdPartySignInUpPOST( originalImplementation, fastify, ), @@ -117,26 +134,10 @@ const getThirdPartyEmailPasswordRecipeConfig = ( }; }, }, + providers: getThirdPartyProviders(config), signUpFeature: { formFields: getFormFields(config), }, - emailDelivery: { - override: (originalImplementation) => { - let sendEmailConfig: SendEmailWrapper | undefined; - - if (thirdPartyEmailPassword?.sendEmail) { - sendEmailConfig = thirdPartyEmailPassword.sendEmail; - } - - return { - ...originalImplementation, - sendEmail: sendEmailConfig - ? sendEmailConfig(originalImplementation, fastify) - : sendPasswordResetEmail(originalImplementation, fastify), - }; - }, - }, - providers: getThirdPartyProviders(config), }; }; diff --git a/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts b/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts index d0b5edac6..609c9c2a2 100644 --- a/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts +++ b/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts @@ -1,18 +1,18 @@ -import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; + const getThirdPartyProviders = (config: ApiConfig) => { const { Apple, Facebook, Github, Google } = ThirdPartyEmailPassword; const providersConfig = config.user.supertokens.providers; const providers: TypeProvider[] = []; const providerFunctions = [ - { name: "google", initProvider: Google }, - { name: "github", initProvider: Github }, - { name: "facebook", initProvider: Facebook }, - { name: "apple", initProvider: Apple }, + { initProvider: Google, name: "google" }, + { initProvider: Github, name: "github" }, + { initProvider: Facebook, name: "facebook" }, + { initProvider: Apple, name: "apple" }, ]; for (const provider of providerFunctions) { diff --git a/packages/user/src/supertokens/recipes/index.ts b/packages/user/src/supertokens/recipes/index.ts index 46d702268..0c589bc40 100644 --- a/packages/user/src/supertokens/recipes/index.ts +++ b/packages/user/src/supertokens/recipes/index.ts @@ -1,11 +1,11 @@ +import type { FastifyInstance } from "fastify"; +import type { RecipeListFunction } from "supertokens-node/types"; + import initEmailVerificationRecipe from "./initEmailVerificationRecipe"; import initSessionRecipe from "./initSessionRecipe"; import initThirdPartyEmailPassword from "./initThirdPartyEmailPasswordRecipe"; import initUserRolesRecipe from "./initUserRolesRecipe"; -import type { FastifyInstance } from "fastify"; -import type { RecipeListFunction } from "supertokens-node/types"; - const getRecipeList = (fastify: FastifyInstance): RecipeListFunction[] => { const recipeList = [ initSessionRecipe(fastify), diff --git a/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts b/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts index 8fef110a7..2568a2bb7 100644 --- a/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts +++ b/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts @@ -1,9 +1,10 @@ -import EmailVerification from "supertokens-node/recipe/emailverification"; +import type { FastifyInstance } from "fastify"; -import getEmailVerificationRecipeConfig from "./config/emailVerificationRecipeConfig"; +import EmailVerification from "supertokens-node/recipe/emailverification"; import type { SupertokensRecipes } from "../types"; -import type { FastifyInstance } from "fastify"; + +import getEmailVerificationRecipeConfig from "./config/emailVerificationRecipeConfig"; const init = (fastify: FastifyInstance) => { const emailVerification: SupertokensRecipes["emailVerification"] = diff --git a/packages/user/src/supertokens/recipes/initSessionRecipe.ts b/packages/user/src/supertokens/recipes/initSessionRecipe.ts index 571f51439..3adf21646 100644 --- a/packages/user/src/supertokens/recipes/initSessionRecipe.ts +++ b/packages/user/src/supertokens/recipes/initSessionRecipe.ts @@ -1,9 +1,10 @@ -import Session from "supertokens-node/recipe/session"; +import type { FastifyInstance } from "fastify"; -import getSessionRecipeConfig from "./config/sessionRecipeConfig"; +import Session from "supertokens-node/recipe/session"; import type { SupertokensRecipes } from "../types"; -import type { FastifyInstance } from "fastify"; + +import getSessionRecipeConfig from "./config/sessionRecipeConfig"; const init = (fastify: FastifyInstance) => { const session: SupertokensRecipes["session"] = diff --git a/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts b/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts index 1434bf53c..4484d89d7 100644 --- a/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts +++ b/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts @@ -1,9 +1,10 @@ -import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { FastifyInstance } from "fastify"; -import getThirdPartyEmailPasswordRecipeConfig from "./config/thirdPartyEmailPasswordRecipeConfig"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; import type { SupertokensRecipes } from "../types"; -import type { FastifyInstance } from "fastify"; + +import getThirdPartyEmailPasswordRecipeConfig from "./config/thirdPartyEmailPasswordRecipeConfig"; const init = (fastify: FastifyInstance) => { const thirdPartyEmailPassword: SupertokensRecipes["thirdPartyEmailPassword"] = diff --git a/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts b/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts index b2b1f412c..c5b651ac1 100644 --- a/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts +++ b/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts @@ -1,9 +1,10 @@ -import UserRoles from "supertokens-node/recipe/userroles"; +import type { FastifyInstance } from "fastify"; -import getUserRolesRecipeConfig from "./config/userRolesRecipeConfig"; +import UserRoles from "supertokens-node/recipe/userroles"; import type { SupertokensRecipes } from "../types"; -import type { FastifyInstance } from "fastify"; + +import getUserRolesRecipeConfig from "./config/userRolesRecipeConfig"; const init = (fastify: FastifyInstance) => { const recipes = fastify.config.user.supertokens.recipes as SupertokensRecipes; diff --git a/packages/user/src/supertokens/types/emailVerificationRecipe.ts b/packages/user/src/supertokens/types/emailVerificationRecipe.ts index 0785c7c69..295fce14b 100644 --- a/packages/user/src/supertokens/types/emailVerificationRecipe.ts +++ b/packages/user/src/supertokens/types/emailVerificationRecipe.ts @@ -1,13 +1,13 @@ -import EmailVerification from "supertokens-node/recipe/emailverification"; - import type { FastifyInstance } from "fastify"; import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; import type { - TypeEmailVerificationEmailDeliveryInput, APIInterface, RecipeInterface, + TypeEmailVerificationEmailDeliveryInput, } from "supertokens-node/recipe/emailverification/types"; +import EmailVerification from "supertokens-node/recipe/emailverification"; + type APIInterfaceWrapper = { [key in keyof APIInterface]?: ( originalImplementation: APIInterface, @@ -15,6 +15,15 @@ type APIInterfaceWrapper = { ) => APIInterface[key]; }; +interface EmailVerificationRecipe { + mode?: "OPTIONAL" | "REQUIRED"; + override?: { + apis?: APIInterfaceWrapper; + functions?: RecipeInterfaceWrapper; + }; + sendEmail?: SendEmailWrapper; +} + type RecipeInterfaceWrapper = { [key in keyof RecipeInterface]?: ( originalImplementation: RecipeInterface, @@ -27,18 +36,9 @@ type SendEmailWrapper = ( fastify: FastifyInstance, ) => typeof EmailVerification.sendEmail; -interface EmailVerificationRecipe { - override?: { - apis?: APIInterfaceWrapper; - functions?: RecipeInterfaceWrapper; - }; - mode?: "REQUIRED" | "OPTIONAL"; - sendEmail?: SendEmailWrapper; -} - export type { APIInterfaceWrapper, - RecipeInterfaceWrapper, EmailVerificationRecipe, + RecipeInterfaceWrapper, SendEmailWrapper, }; diff --git a/packages/user/src/supertokens/types/index.ts b/packages/user/src/supertokens/types/index.ts index b22ad8302..a7df5c2ee 100644 --- a/packages/user/src/supertokens/types/index.ts +++ b/packages/user/src/supertokens/types/index.ts @@ -1,3 +1,10 @@ +import type { FastifyInstance } from "fastify"; +import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types"; +import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types"; +import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import type { TypeInput as UserRolesRecipeConfig } from "supertokens-node/recipe/userroles/types"; + import { Apple, Facebook, @@ -8,31 +15,6 @@ import { import type { EmailVerificationRecipe } from "./emailVerificationRecipe"; import type { SessionRecipe } from "./sessionRecipe"; import type { ThirdPartyEmailPasswordRecipe } from "./thirdPartyEmailPasswordRecipe"; -import type { FastifyInstance } from "fastify"; -import type { TypeInput as EmailVerificationRecipeConfig } from "supertokens-node/recipe/emailverification/types"; -import type { TypeInput as SessionRecipeConfig } from "supertokens-node/recipe/session/types"; -import type { TypeProvider } from "supertokens-node/recipe/thirdpartyemailpassword"; -import type { TypeInput as ThirdPartyEmailPasswordRecipeConfig } from "supertokens-node/recipe/thirdpartyemailpassword/types"; -import type { TypeInput as UserRolesRecipeConfig } from "supertokens-node/recipe/userroles/types"; - -interface SupertokensRecipes { - emailVerification?: - | EmailVerificationRecipe - | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig); - session?: SessionRecipe | ((fastify: FastifyInstance) => SessionRecipeConfig); - userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; - thirdPartyEmailPassword?: - | ThirdPartyEmailPasswordRecipe - | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig); -} - -interface SupertokensThirdPartyProvider { - apple?: Parameters[0][]; - facebook?: Parameters[0]; - github?: Parameters[0]; - google?: Parameters[0]; - custom?: TypeProvider[]; -} interface SupertokensConfig { apiBasePath?: string; @@ -41,13 +23,32 @@ interface SupertokensConfig { */ checkSessionInDatabase?: boolean; connectionUri: string; + emailVerificationPath?: string; providers?: SupertokensThirdPartyProvider; recipes?: SupertokensRecipes; refreshTokenCookiePath?: string; resetPasswordPath?: string; - emailVerificationPath?: string; sendUserAlreadyExistsWarning?: boolean; setErrorHandler?: boolean; } +interface SupertokensRecipes { + emailVerification?: + | ((fastify: FastifyInstance) => EmailVerificationRecipeConfig) + | EmailVerificationRecipe; + session?: ((fastify: FastifyInstance) => SessionRecipeConfig) | SessionRecipe; + thirdPartyEmailPassword?: + | ((fastify: FastifyInstance) => ThirdPartyEmailPasswordRecipeConfig) + | ThirdPartyEmailPasswordRecipe; + userRoles?: (fastify: FastifyInstance) => UserRolesRecipeConfig; +} + +interface SupertokensThirdPartyProvider { + apple?: Parameters[0][]; + custom?: TypeProvider[]; + facebook?: Parameters[0]; + github?: Parameters[0]; + google?: Parameters[0]; +} + export type { SupertokensConfig, SupertokensRecipes }; diff --git a/packages/user/src/supertokens/types/sessionRecipe.ts b/packages/user/src/supertokens/types/sessionRecipe.ts index 105d78d24..189076f90 100644 --- a/packages/user/src/supertokens/types/sessionRecipe.ts +++ b/packages/user/src/supertokens/types/sessionRecipe.ts @@ -3,9 +3,9 @@ import type { BaseRequest } from "supertokens-node/lib/build/framework"; import type { TypeInput as OpenIdTypeInput } from "supertokens-node/lib/build/recipe/openid/types"; import type { APIInterface, + ErrorHandlers, RecipeInterface, TokenTransferMethod, - ErrorHandlers, } from "supertokens-node/recipe/session/types"; type APIInterfaceWrapper = { @@ -23,27 +23,27 @@ type RecipeInterfaceWrapper = { }; interface SessionRecipe { - useDynamicAccessTokenSigningKey?: boolean; - sessionExpiredStatusCode?: number; - invalidClaimStatusCode?: number; accessTokenPath?: string; - cookieSecure?: boolean; - cookieSameSite?: "strict" | "lax" | "none"; + antiCsrf?: "NONE" | "VIA_CUSTOM_HEADER" | "VIA_TOKEN"; cookieDomain?: string; + cookieSameSite?: "lax" | "none" | "strict"; + cookieSecure?: boolean; + errorHandlers?: ErrorHandlers; + exposeAccessTokenToFrontendInCookieBasedAuth?: boolean; getTokenTransferMethod?: (input: { - req: BaseRequest; forCreateNewSession: boolean; + req: BaseRequest; // eslint-disable-next-line @typescript-eslint/no-explicit-any userContext: any; - }) => TokenTransferMethod | "any"; - errorHandlers?: ErrorHandlers; - antiCsrf?: "VIA_TOKEN" | "VIA_CUSTOM_HEADER" | "NONE"; - exposeAccessTokenToFrontendInCookieBasedAuth?: boolean; + }) => "any" | TokenTransferMethod; + invalidClaimStatusCode?: number; override?: { apis?: APIInterfaceWrapper; functions?: RecipeInterfaceWrapper; openIdFeature?: OpenIdTypeInput["override"]; }; + sessionExpiredStatusCode?: number; + useDynamicAccessTokenSigningKey?: boolean; } export type { APIInterfaceWrapper, RecipeInterfaceWrapper, SessionRecipe }; diff --git a/packages/user/src/supertokens/types/thirdPartyEmailPasswordRecipe.ts b/packages/user/src/supertokens/types/thirdPartyEmailPasswordRecipe.ts index 16102f2b3..dda04aac7 100644 --- a/packages/user/src/supertokens/types/thirdPartyEmailPasswordRecipe.ts +++ b/packages/user/src/supertokens/types/thirdPartyEmailPasswordRecipe.ts @@ -1,5 +1,3 @@ -import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; - import type { FastifyInstance } from "fastify"; import type { EmailDeliveryInterface } from "supertokens-node/lib/build/ingredients/emaildelivery/types"; import type { TypeEmailPasswordPasswordResetEmailDeliveryInput } from "supertokens-node/lib/build/recipe/emailpassword/types"; @@ -9,6 +7,8 @@ import type { TypeInputSignUp, } from "supertokens-node/recipe/thirdpartyemailpassword/types"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; + type APIInterfaceWrapper = { [key in keyof APIInterface]?: ( originalImplementation: APIInterface, @@ -16,11 +16,6 @@ type APIInterfaceWrapper = { ) => APIInterface[key]; }; -type SendEmailWrapper = ( - originalImplementation: EmailDeliveryInterface, - fastify: FastifyInstance, -) => typeof ThirdPartyEmailPassword.sendEmail; - type RecipeInterfaceWrapper = { [key in keyof RecipeInterface]?: ( originalImplementation: RecipeInterface, @@ -28,6 +23,11 @@ type RecipeInterfaceWrapper = { ) => RecipeInterface[key]; }; +type SendEmailWrapper = ( + originalImplementation: EmailDeliveryInterface, + fastify: FastifyInstance, +) => typeof ThirdPartyEmailPassword.sendEmail; + interface ThirdPartyEmailPasswordRecipe { override?: { apis?: APIInterfaceWrapper; diff --git a/packages/user/src/supertokens/utils/__test__/profileValidationClaim.spec.ts b/packages/user/src/supertokens/utils/__test__/profileValidationClaim.spec.ts new file mode 100644 index 000000000..4b6bc79ff --- /dev/null +++ b/packages/user/src/supertokens/utils/__test__/profileValidationClaim.spec.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ProfileValidationClaim from "../profileValidationClaim"; + +const FIXED_NOW = new Date("2024-06-15T12:00:00.000Z").getTime(); + +const makePayload = ( + value: undefined | { gracePeriodEndsAt?: number; isVerified: boolean }, +) => { + if (value === undefined) { + return {}; + } + + return { + profileValidation: { + t: FIXED_NOW, + v: value, + }, + }; +}; + +describe("ProfileValidationClaim", () => { + let claim: ProfileValidationClaim; + + beforeEach(() => { + claim = new ProfileValidationClaim(); + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("static properties", () => { + it("has key 'profileValidation'", () => { + expect(ProfileValidationClaim.key).toBe("profileValidation"); + }); + + it("has defaultMaxAgeInSeconds as undefined", () => { + expect(ProfileValidationClaim.defaultMaxAgeInSeconds).toBeUndefined(); + }); + }); + + describe("getValueFromPayload", () => { + it("returns undefined when key is absent from payload", () => { + expect(claim.getValueFromPayload({}, {})).toBeUndefined(); + }); + + it("returns the value when present in payload", () => { + const payload = makePayload({ isVerified: true }); + + expect(claim.getValueFromPayload(payload, {})).toEqual({ + isVerified: true, + }); + }); + }); + + describe("getLastRefetchTime", () => { + it("returns undefined when key is absent from payload", () => { + expect(claim.getLastRefetchTime({}, {})).toBeUndefined(); + }); + + it("returns the timestamp when present in payload", () => { + const payload = makePayload({ isVerified: true }); + + expect(claim.getLastRefetchTime(payload, {})).toBe(FIXED_NOW); + }); + }); + + describe("addToPayload_internal", () => { + it("adds profileValidation to the payload with value and current timestamp", () => { + const result = claim.addToPayload_internal({}, { isVerified: true }, {}); + + expect(result.profileValidation).toBeDefined(); + expect(result.profileValidation.v).toEqual({ isVerified: true }); + expect(result.profileValidation.t).toBe(FIXED_NOW); + }); + + it("preserves existing payload properties", () => { + const result = claim.addToPayload_internal( + { other: "value" }, + { isVerified: true }, + {}, + ); + + expect(result.other).toBe("value"); + }); + }); + + describe("removeFromPayload", () => { + it("removes the profileValidation key from payload", () => { + const payload = makePayload({ isVerified: true }); + const result = claim.removeFromPayload(payload, {}); + + expect(result.profileValidation).toBeUndefined(); + }); + + it("preserves other payload properties", () => { + const payload = { ...makePayload({ isVerified: true }), other: "value" }; + const result = claim.removeFromPayload(payload, {}); + + expect(result.other).toBe("value"); + }); + + it("does not mutate the original payload", () => { + const payload = makePayload({ isVerified: true }); + claim.removeFromPayload(payload, {}); + + expect(payload.profileValidation).toBeDefined(); + }); + }); + + describe("removeFromPayloadByMerge_internal", () => { + it("sets the profileValidation key to null (merge-style removal)", () => { + const payload = makePayload({ isVerified: true }); + const result = claim.removeFromPayloadByMerge_internal(payload, {}); + + expect(result.profileValidation).toBeNull(); + }); + + it("preserves other payload properties", () => { + const payload = { ...makePayload({ isVerified: true }), other: "value" }; + const result = claim.removeFromPayloadByMerge_internal(payload, {}); + + expect(result.other).toBe("value"); + }); + }); + + describe("validators.isVerified", () => { + describe("shouldRefetch", () => { + it("always returns true", () => { + const validator = claim.validators.isVerified(); + + expect(validator.shouldRefetch({})).toBe(true); + }); + }); + + describe("validate", () => { + it("returns isValid: false with 'value does not exist' when claim is absent", async () => { + const validator = claim.validators.isVerified(); + const result = await validator.validate({}, {}); + + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.reason.message).toBe("value does not exist"); + expect(result.reason.expectedValue).toBe(true); + expect(result.reason.actualValue).toBeUndefined(); + } + }); + + it("returns isValid: true when profile is verified", async () => { + const validator = claim.validators.isVerified(); + const payload = makePayload({ isVerified: true }); + + const result = await validator.validate(payload, {}); + + expect(result.isValid).toBe(true); + }); + + it("returns isValid: false with 'User profile is incomplete' when not verified and no grace period", async () => { + const validator = claim.validators.isVerified(); + const payload = makePayload({ isVerified: false }); + + const result = await validator.validate(payload, {}); + + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.reason.message).toBe("User profile is incomplete"); + expect(result.reason.expectedValue).toBe(true); + expect(result.reason.actualValue).toBe(false); + } + }); + + it("returns isValid: true when not verified but still within grace period", async () => { + const validator = claim.validators.isVerified(); + const gracePeriodEndsAt = FIXED_NOW + 1000 * 60 * 60 * 24; // 1 day from now + const payload = makePayload({ gracePeriodEndsAt, isVerified: false }); + + const result = await validator.validate(payload, {}); + + expect(result.isValid).toBe(true); + }); + + it("returns isValid: false when not verified and grace period has expired", async () => { + const validator = claim.validators.isVerified(); + const gracePeriodEndsAt = FIXED_NOW - 1; // 1ms in the past + const payload = makePayload({ gracePeriodEndsAt, isVerified: false }); + + const result = await validator.validate(payload, {}); + + expect(result.isValid).toBe(false); + if (!result.isValid) { + expect(result.reason.message).toBe("User profile is incomplete"); + } + }); + + it("uses the custom id when provided to isVerified()", () => { + const validator = claim.validators.isVerified( + undefined, + "custom-claim-id", + ); + + expect(validator.id).toBe("custom-claim-id"); + }); + + it("defaults to the claim key as validator id", () => { + const validator = claim.validators.isVerified(); + + expect(validator.id).toBe("profileValidation"); + }); + }); + }); +}); diff --git a/packages/user/src/supertokens/utils/createUserContext.ts b/packages/user/src/supertokens/utils/createUserContext.ts index f2c671d77..d5d637998 100644 --- a/packages/user/src/supertokens/utils/createUserContext.ts +++ b/packages/user/src/supertokens/utils/createUserContext.ts @@ -1,8 +1,8 @@ -import { FastifyRequest as SupertokensFastifyRequest } from "supertokens-node/lib/build/framework/fastify/framework"; - import type { FastifyRequest } from "fastify"; import type { SessionRequest } from "supertokens-node/lib/build/framework/fastify"; +import { FastifyRequest as SupertokensFastifyRequest } from "supertokens-node/lib/build/framework/fastify/framework"; + // reference https://github.com/supertokens/supertokens-node/blob/0faebfae435fd661f4b6657e2ca510101da012f5/lib/ts/utils.ts#L143 const createUserContext = ( diff --git a/packages/user/src/supertokens/utils/profileValidationClaim.ts b/packages/user/src/supertokens/utils/profileValidationClaim.ts index 072f53328..be4fd61dc 100644 --- a/packages/user/src/supertokens/utils/profileValidationClaim.ts +++ b/packages/user/src/supertokens/utils/profileValidationClaim.ts @@ -4,12 +4,12 @@ // reference https://github.com/supertokens/supertokens-node/blob/master/lib/ts/recipe/session/claimBaseClasses/primitiveArrayClaim.ts -import { getRequestFromUserContext } from "supertokens-node"; -import { SessionClaim } from "supertokens-node/lib/build/recipe/session/claims"; - import type { SessionRequest } from "supertokens-node/framework/fastify"; import type { SessionClaimValidator } from "supertokens-node/recipe/session"; +import { getRequestFromUserContext } from "supertokens-node"; +import { SessionClaim } from "supertokens-node/lib/build/recipe/session/claims"; + interface Response { gracePeriodEndsAt?: number; isVerified: boolean; @@ -19,6 +19,55 @@ class ProfileValidationClaim extends SessionClaim { public static defaultMaxAgeInSeconds: number | undefined = undefined; public static key = "profileValidation"; + validators = { + isVerified: ( + maxAgeInSeconds: + | number + | undefined = ProfileValidationClaim.defaultMaxAgeInSeconds, + id?: string, + ): SessionClaimValidator => { + return { + claim: this, + id: id ?? this.key, + shouldRefetch: () => true, + validate: async (payload, context) => { + const expectedValue = true; + + const claimValue = this.getValueFromPayload(payload, context); + + if (claimValue === undefined) { + return { + isValid: false, + reason: { + actualValue: undefined, + expectedValue, + message: "value does not exist", + }, + }; + } + + if ( + claimValue.isVerified !== expectedValue && + (claimValue.gracePeriodEndsAt + ? claimValue.gracePeriodEndsAt <= Date.now() + : true) + ) { + return { + isValid: false, + reason: { + actualValue: claimValue.isVerified, + expectedValue, + message: "User profile is incomplete", + }, + }; + } + + return { isValid: true }; + }, + }; + }, + }; + constructor() { super("profileValidation"); } @@ -27,8 +76,8 @@ class ProfileValidationClaim extends SessionClaim { return { ...payload, [this.key]: { - v: value, t: Date.now(), + v: value, }, }; } @@ -97,55 +146,6 @@ class ProfileValidationClaim extends SessionClaim { return res; } - - validators = { - isVerified: ( - maxAgeInSeconds: - | number - | undefined = ProfileValidationClaim.defaultMaxAgeInSeconds, - id?: string, - ): SessionClaimValidator => { - return { - claim: this, - id: id ?? this.key, - shouldRefetch: () => true, - validate: async (payload, context) => { - const expectedValue = true; - - const claimValue = this.getValueFromPayload(payload, context); - - if (claimValue === undefined) { - return { - isValid: false, - reason: { - message: "value does not exist", - expectedValue, - actualValue: undefined, - }, - }; - } - - if ( - claimValue.isVerified !== expectedValue && - (claimValue.gracePeriodEndsAt - ? claimValue.gracePeriodEndsAt <= Date.now() - : true) - ) { - return { - isValid: false, - reason: { - message: "User profile is incomplete", - expectedValue, - actualValue: claimValue.isVerified, - }, - }; - } - - return { isValid: true }; - }, - }; - }, - }; } export default ProfileValidationClaim; diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index d98cd658f..1b965494f 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -1,14 +1,15 @@ -import invitationHandlers from "../model/invitations/handlers"; -import InvitationService from "../model/invitations/service"; -import userHandlers from "../model/users/handlers"; -import UserService from "../model/users/service"; +import type { FastifyRequest } from "fastify"; import type { SupertokensConfig } from "../supertokens"; import type { Invitation } from "./invitation"; import type { IsEmailOptions } from "./isEmailOptions"; import type { StrongPasswordOptions } from "./strongPasswordOptions"; import type { User, UserUpdateInput } from "./user"; -import type { FastifyRequest } from "fastify"; + +import invitationHandlers from "../model/invitations/handlers"; +import InvitationService from "../model/invitations/service"; +import userHandlers from "../model/users/handlers"; +import UserService from "../model/users/service"; interface EmailOptions { subject?: string; @@ -37,14 +38,14 @@ interface UserConfig { gracePeriodInDays?: number; }; signUp?: { - /** - * @default true - */ - enabled?: boolean; /** * @default false */ emailVerification?: boolean; + /** + * @default true + */ + enabled?: boolean; }; updateEmail?: { enabled?: boolean; diff --git a/packages/user/src/types/index.ts b/packages/user/src/types/index.ts index 1d243f0a8..ba9be6735 100644 --- a/packages/user/src/types/index.ts +++ b/packages/user/src/types/index.ts @@ -2,15 +2,15 @@ import type { PaginatedList } from "@prefabs.tech/fastify-slonik"; import type { MercuriusContext } from "mercurius"; import type { QueryResultRow } from "slonik"; -interface ChangePasswordInput { - oldPassword?: string; - newPassword?: string; -} - interface ChangeEmailInput { email: string; } +interface ChangePasswordInput { + newPassword?: string; + oldPassword?: string; +} + interface EmailErrorMessages { invalid?: string; required?: string; @@ -28,7 +28,7 @@ interface Resolver { [key: string]: unknown; }, context: MercuriusContext, - ) => Promise>; + ) => Promise | QueryResultRow>; } export type { @@ -39,12 +39,12 @@ export type { Resolver, }; +export type { EmailVerificationRecipe } from "../supertokens/types/emailVerificationRecipe"; +export type { SessionRecipe } from "../supertokens/types/sessionRecipe"; +export type { ThirdPartyEmailPasswordRecipe } from "../supertokens/types/thirdPartyEmailPasswordRecipe"; export * from "./config"; export * from "./invitation"; + export * from "./isEmailOptions"; export * from "./strongPasswordOptions"; export * from "./user"; - -export type { EmailVerificationRecipe } from "../supertokens/types/emailVerificationRecipe"; -export type { SessionRecipe } from "../supertokens/types/sessionRecipe"; -export type { ThirdPartyEmailPasswordRecipe } from "../supertokens/types/thirdPartyEmailPasswordRecipe"; diff --git a/packages/user/src/types/invitation.ts b/packages/user/src/types/invitation.ts index 94769ffb0..316cd2d94 100644 --- a/packages/user/src/types/invitation.ts +++ b/packages/user/src/types/invitation.ts @@ -1,31 +1,31 @@ import type { User } from "./index"; interface Invitation { - id: number; acceptedAt?: number; appId?: number; + createdAt: number; email: string; expiresAt: number; + id: number; invitedBy?: User; invitedById: string; payload?: Record; revokedAt?: number; role: string; token: string; - createdAt: number; updatedAt: number; } type InvitationCreateInput = Omit< Invitation, - | "id" | "acceptedAt" + | "createdAt" | "expiresAt" + | "id" | "invitedBy" | "payload" | "revokedAt" | "token" - | "createdAt" | "updatedAt" > & { expiresAt?: string; @@ -35,18 +35,18 @@ type InvitationCreateInput = Omit< type InvitationUpdateInput = Partial< Omit< Invitation, - | "id" | "acceptedAt" | "appId" + | "createdAt" | "email" | "expiresAt" + | "id" | "invitedBy" | "invitedById" | "payload" | "revokedAt" | "role" | "token" - | "createdAt" | "updatedAt" > & { acceptedAt: string; diff --git a/packages/user/src/types/strongPasswordOptions.ts b/packages/user/src/types/strongPasswordOptions.ts index a002fac53..f021b9445 100644 --- a/packages/user/src/types/strongPasswordOptions.ts +++ b/packages/user/src/types/strongPasswordOptions.ts @@ -14,25 +14,25 @@ interface StrongPasswordOptions { minLowercase?: number | undefined; /** - * Minimum number of upercase letters + * Minimum number of numbers * * @default 1 */ - minUppercase?: number | undefined; + minNumbers?: number | undefined; /** - * Minimum number of numbers + * Minimum number of symbols * * @default 1 */ - minNumbers?: number | undefined; + minSymbols?: number | undefined; /** - * Minimum number of symbols + * Minimum number of upercase letters * * @default 1 */ - minSymbols?: number | undefined; + minUppercase?: number | undefined; /** * Whether or not the validator should return the score @@ -42,25 +42,27 @@ interface StrongPasswordOptions { // returnScore?: false | undefined; /** - * Points earned for each unique character + * Points earned for containing lowercase characters * - * @default 1 + * @default 10 */ - pointsPerUnique?: number | undefined; + pointsForContainingLower?: number | undefined; /** - * Point earned for each repeated character + * Points earned for containing numbers + * + * @default 10 * - * @default 0.5 */ - pointsPerRepeat?: number | undefined; + pointsForContainingNumber?: number | undefined; /** - * Points earned for containing lowercase characters + * Points earned for containing symbols * * @default 10 + * */ - pointsForContainingLower?: number | undefined; + pointsForContainingSymbol?: number | undefined; /** * Points earned for containing uppercase characters @@ -70,20 +72,18 @@ interface StrongPasswordOptions { pointsForContainingUpper?: number | undefined; /** - * Points earned for containing numbers - * - * @default 10 + * Point earned for each repeated character * + * @default 0.5 */ - pointsForContainingNumber?: number | undefined; + pointsPerRepeat?: number | undefined; /** - * Points earned for containing symbols - * - * @default 10 + * Points earned for each unique character * + * @default 1 */ - pointsForContainingSymbol?: number | undefined; + pointsPerUnique?: number | undefined; } export type { StrongPasswordOptions }; diff --git a/packages/user/src/types/user.ts b/packages/user/src/types/user.ts index a438b80dd..6c846d0e3 100644 --- a/packages/user/src/types/user.ts +++ b/packages/user/src/types/user.ts @@ -1,19 +1,21 @@ import type { Multipart } from "@prefabs.tech/fastify-s3"; import type { User as SupertokensUser } from "supertokens-node/recipe/thirdpartyemailpassword"; +interface AuthUser extends SupertokensUser, User {} + interface Photo { id: number; url: string; } interface User { - id: string; deletedAt?: number; disabled: boolean; email: string; + id: string; lastLoginAt: number; - photoId?: number | null; photo?: Photo; + photoId?: null | number; roles?: string[]; signedUpAt: number; } @@ -21,7 +23,7 @@ interface User { type UserCreateInput = Partial< Omit< User, - "disabled" | "lastLoginAt" | "roles" | "signedUpAt" | "deletedAt" | "photo" + "deletedAt" | "disabled" | "lastLoginAt" | "photo" | "roles" | "signedUpAt" > > & { lastLoginAt?: string; @@ -31,19 +33,17 @@ type UserCreateInput = Partial< type UserUpdateInput = Partial< Omit< User, - | "id" + | "deletedAt" | "email" + | "id" | "lastLoginAt" + | "photo" | "roles" | "signedUpAt" - | "deletedAt" - | "photo" > > & { lastLoginAt?: string; photo?: Multipart; }; -interface AuthUser extends User, SupertokensUser {} - export type { AuthUser, User, UserCreateInput, UserUpdateInput }; diff --git a/packages/user/src/userContext.ts b/packages/user/src/userContext.ts index 6bbda0567..ef482fd68 100644 --- a/packages/user/src/userContext.ts +++ b/packages/user/src/userContext.ts @@ -1,12 +1,12 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { MercuriusContext } from "mercurius"; + import { wrapResponse } from "supertokens-node/framework/fastify"; import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; import Session from "supertokens-node/recipe/session"; import ProfileValidationClaim from "./supertokens/utils/profileValidationClaim"; -import type { FastifyRequest, FastifyReply } from "fastify"; -import type { MercuriusContext } from "mercurius"; - const userContext = async ( context: MercuriusContext, request: FastifyRequest, @@ -14,7 +14,6 @@ const userContext = async ( ) => { try { request.session = (await Session.getSession(request, wrapResponse(reply), { - sessionRequired: false, overrideGlobalClaimValidators: async (globalValidators) => globalValidators.filter( (sessionClaimValidator) => @@ -22,6 +21,7 @@ const userContext = async ( sessionClaimValidator.id, ), ), + sessionRequired: false, })) as (typeof request)["session"]; } catch (error) { if (!Session.Error.isErrorFromSuperTokens(error)) { diff --git a/packages/user/src/validator/__test__/email.spec.ts b/packages/user/src/validator/__test__/email.spec.ts index f95f2b7fd..9470281be 100644 --- a/packages/user/src/validator/__test__/email.spec.ts +++ b/packages/user/src/validator/__test__/email.spec.ts @@ -1,9 +1,9 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + import { beforeEach, describe, expect, it } from "vitest"; import validateEmail from "../email"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; - describe("validateEmail", () => { let config = {} as unknown as ApiConfig; @@ -69,4 +69,17 @@ describe("validateEmail", () => { success: true, }); }); + + it("returns success object when config.user.email is undefined (uses empty options fallback)", () => { + const configWithoutEmail = { + user: {}, + } as unknown as ApiConfig; + + const emailValidation = validateEmail( + "user@example.com", + configWithoutEmail, + ); + + expect(emailValidation).toEqual({ success: true }); + }); }); diff --git a/packages/user/src/validator/__test__/password.spec.ts b/packages/user/src/validator/__test__/password.spec.ts index 85c7aac16..e0b097b69 100644 --- a/packages/user/src/validator/__test__/password.spec.ts +++ b/packages/user/src/validator/__test__/password.spec.ts @@ -1,9 +1,9 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + import { beforeEach, describe, expect, it } from "vitest"; import validatePassword from "../password"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; - describe("validatePassword", () => { let config = {} as unknown as ApiConfig; @@ -77,9 +77,9 @@ describe("validatePassword", () => { config.user.password = { minLength: 1, minLowercase: 1, - minUppercase: 1, minNumbers: 1, minSymbols: 1, + minUppercase: 1, }; const password = "Qwerty12"; @@ -97,9 +97,9 @@ describe("validatePassword", () => { config.user.password = { minLength: 1, minLowercase: 1, - minUppercase: 1, minNumbers: 1, minSymbols: 1, + minUppercase: 1, }; const password = "Qwerty1!"; @@ -115,9 +115,9 @@ describe("validatePassword", () => { config.user.password = { minLength: 2, minLowercase: 2, - minUppercase: 2, minNumbers: 2, minSymbols: 2, + minUppercase: 2, }; const password = "Qwerty12"; @@ -135,9 +135,9 @@ describe("validatePassword", () => { config.user.password = { minLength: 2, minLowercase: 2, - minUppercase: 2, minNumbers: 2, minSymbols: 2, + minUppercase: 2, }; const password = "QwertY12!@"; diff --git a/packages/user/src/validator/email.ts b/packages/user/src/validator/email.ts index 2f6217cfa..dfec33136 100644 --- a/packages/user/src/validator/email.ts +++ b/packages/user/src/validator/email.ts @@ -1,7 +1,7 @@ -import { emailSchema } from "../schemas"; - import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import { emailSchema } from "../schemas"; + const validateEmail = (email: string, config: ApiConfig) => { const result = emailSchema( { diff --git a/packages/user/src/validator/password.ts b/packages/user/src/validator/password.ts index 976acfa11..6e48d9fbb 100644 --- a/packages/user/src/validator/password.ts +++ b/packages/user/src/validator/password.ts @@ -1,15 +1,12 @@ -import { passwordSchema } from "../schemas"; -import { defaultOptions } from "../schemas/password"; +import type { ApiConfig } from "@prefabs.tech/fastify-config"; import type { StrongPasswordOptions } from "../types"; -import type { ApiConfig } from "@prefabs.tech/fastify-config"; -const getErrorMessage = (options?: StrongPasswordOptions): string => { - let errorMessage = "Password is too weak"; +import { passwordSchema } from "../schemas"; +import { defaultOptions } from "../schemas/password"; - if (!options) { - return errorMessage; - } +const getErrorMessage = (options: StrongPasswordOptions): string => { + let errorMessage = "Password is too weak"; const messages: string[] = []; diff --git a/packages/user/vite.config.ts b/packages/user/vite.config.ts index 0036198c2..cdbb234d8 100644 --- a/packages/user/vite.config.ts +++ b/packages/user/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -26,6 +25,8 @@ export default defineConfig(({ mode }) => { output: { exports: "named", globals: { + "@fastify/cors": "FastifyCors", + "@fastify/formbody": "FastifyFormbody", "@prefabs.tech/fastify-config": "PrefabsTechFastifyConfig", "@prefabs.tech/fastify-error-handler": "PrefabsTechFastifyErrorHandler", @@ -33,8 +34,6 @@ export default defineConfig(({ mode }) => { "@prefabs.tech/fastify-mailer": "PrefabsTechFastifyMailer", "@prefabs.tech/fastify-s3": "PrefabsTechFastifyS3", "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", - "@fastify/cors": "FastifyCors", - "@fastify/formbody": "FastifyFormbody", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", humps: "Humps", @@ -48,9 +47,9 @@ export default defineConfig(({ mode }) => { "supertokens-node/lib/build/recipe/session/claims": "claims", "supertokens-node/lib/build/recipe/session/recipe": "SessionRecipe", "supertokens-node/recipe/emailverification": "EmailVerification", + "supertokens-node/recipe/session": "SupertokensSession", "supertokens-node/recipe/session/framework/fastify": "SupertokensSessionFastify", - "supertokens-node/recipe/session": "SupertokensSession", "supertokens-node/recipe/thirdpartyemailpassword": "SupertokensThirdPartyEmailPassword", "supertokens-node/recipe/userroles": "SupertokensUserRoles", diff --git a/packages/worker/ANALYSIS.md b/packages/worker/ANALYSIS.md new file mode 100644 index 000000000..a5a7477fd --- /dev/null +++ b/packages/worker/ANALYSIS.md @@ -0,0 +1,131 @@ + + +# `@prefabs.tech/fastify-worker` — Package Analysis + +A Fastify plugin that orchestrates background work: recurring cron jobs (via `node-cron`) and pluggable queue adapters (BullMQ, SQS) behind a uniform `QueueAdapter` interface and `AdapterRegistry`. The same orchestrator can also be used standalone (without Fastify) via `JobOrchestrator`. + +## Base Library Passthrough Analysis + +### `node-cron` — MODIFIED (thin wrapper) + +- Options type: `TaskOptions` is imported and exposed verbatim on `CronJob.options`. +- Options passed: unmodified. `CronScheduler.schedule()` calls `cron.schedule(job.expression, job.task, job.options)` directly. +- Features restricted: none of `cron.schedule` is restricted, but we never expose individual `ScheduledTask` handles — callers only get bulk lifecycle (`scheduler.stopAll()`). +- Features added: + - Tracking of every scheduled task in an internal array so `stopAll()` can stop the whole set and reset the registry. + - Integration into `JobOrchestrator.start/shutdown` so cron jobs are bound to the Fastify lifecycle. + +### `bullmq` — MODIFIED + +- Options type: we define `BullMQAdapterConfig` that wraps bullmq types — `QueueOptions`, `WorkerOptions`, `Job`, and `JobsOptions` are passed through as-is from `bullmq`. +- Options passed: mostly unmodified, with one transformation: + - `workerOptions.connection` defaults to `queueOptions.connection` if not overridden (i.e., the connection is shared by default but can be diverged). + - `push()` always uses `this.queueName` as the job name; callers cannot pass a custom name. +- Features restricted: + - No direct exposure of `QueueEvents` / `FlowProducer` / `JobScheduler` — only `Queue` + `Worker`. + - Only two worker events are surfaced via callbacks: `error` and `failed` (no `completed`, `active`, `progress`, `drained`, etc.). + - `push` returns `job.id!` (non-null assertion); callers don't get the full `Job` instance back. +- Features added: + - Pluggable `onError(error)` and `onFailed(job, error)` callbacks. + - Custom error wrapping in `push()`: `"Failed to push job to BullMQ queue: ${name}. Error: ${message}"`. + - Lifecycle methods (`start`, `shutdown`) consistent with `SQSAdapter` so both can be uniformly managed by `AdapterRegistry` / `JobOrchestrator`. + - Conforms to the `QueueAdapter` abstract interface (`queueName`, `start`, `shutdown`, `getClient`, `push`). + +### `@aws-sdk/client-sqs` — MODIFIED + +- Options type: `SQSAdapterConfig` is a custom shape. It accepts `SQSClientConfig` and `ReceiveMessageCommandInput` from the SDK verbatim, but we own everything else (`handler`, `onError`, `queueUrl`). +- Options passed: + - `SQSClient` is constructed with `config.clientConfig` directly. + - `ReceiveMessageCommand` is built with `QueueUrl: config.queueUrl` and a default `WaitTimeSeconds: 20`, then spreads `config.receiveMessageOptions` last (so callers can override `WaitTimeSeconds`, set `MaxNumberOfMessages`, etc.). + - `DeleteMessageCommand` always uses `config.queueUrl` and the in-flight message's `ReceiptHandle`. + - `SendMessageCommand` always uses `config.queueUrl` and a JSON-stringified body; caller-provided `options` are spread last and may override `MessageBody` / `QueueUrl`. +- Features restricted: + - No FIFO-specific helpers exposed (callers must pass `MessageGroupId`/`MessageDeduplicationId` via `push` options). + - No batch send/receive / change-visibility commands. + - Messages are always JSON-parsed; raw / binary payloads are not supported. +- Features added: + - Long-polling **default** (`WaitTimeSeconds: 20`). + - Continuous poll loop (`startPolling` / `poll`) that is idempotent (guarded by `isPolling`). + - **Exponential backoff with jitter** on `ReceiveMessageCommand` failure: base 500 ms, doubling each consecutive error, capped at 8000 ms, plus ~25% random jitter. + - Parallel processing of received messages (`Promise.all` over `response.Messages`). + - JSON parsing of `message.Body` with explicit empty/`null` body check; parse failures route to `onError(error, message)` and the message is **not** deleted. + - Handler-success deletes the message via `DeleteMessageCommand`; handler-failure routes to `onError(error, message)` and leaves the message for redelivery. + - Graceful shutdown: flips `isPolling = false`, awaits the in-flight `pollPromise`, then calls `client.destroy()`. Comment explicitly notes this avoids "client destroyed" errors and lets in-progress handlers finish. + - Custom error wrapping in `push()`: `"Failed to push job to SQS queue: ${name}. Error: ${message}"`. + - Conforms to the `QueueAdapter` abstract interface. + +## Summary + +### Public exports + +- **default export** (`plugin.ts`) — `fastify-plugin`-wrapped Fastify plugin. +- `JobOrchestrator` (class) — top-level orchestrator. Constructor takes `WorkerConfig`. Exposes: + - `adapters: AdapterRegistry` (readonly). + - `cron: CronScheduler` (readonly). + - `start(): Promise` — schedules every `cronJobs[i]` and creates/starts every `queues[i]` adapter, adding it to the registry. + - `shutdown(): Promise` — calls `cron.stopAll()` then `adapters.shutdownAll()`. +- `CronScheduler` (class) — thin `node-cron` wrapper with `schedule(job)` and `stopAll()`; tracks tasks internally. +- `AdapterRegistry` (class) — `Map` keyed by `adapter.queueName`. Methods: `add`, `get(name)`, `getAll()`, `has(name)`, `remove(name)`, `shutdownAll()` (awaits each `adapter.shutdown()` in sequence, then clears the map). +- `createQueueAdapter(config)` (factory) — switch on `config.provider`: + - `BULLMQ` → requires `bullmqConfig`, otherwise throws `"BullMQ configuration is required for queue: ${name}"`. + - `SQS` → requires `sqsConfig`, otherwise throws `"SQS configuration is required for queue: ${name}"`. + - default → throws `"Unsupported queue provider: ${provider}"`. +- `QueueAdapter` (abstract class) — common interface: `queueName`, `start()`, `shutdown()`, `getClient()`, `push(data, options?)`. +- `BullMQAdapter` (class) — concrete adapter (see passthrough analysis). +- `SQSAdapter` (class) — concrete adapter (see passthrough analysis). +- `BullMQAdapterConfig`, `SQSAdapterConfig`, `WorkerConfig`, `CronJob`, `QueueConfig` (types). +- `QueueProvider` enum — `SQS = "sqs"`, `BULLMQ = "bullmq"`. +- Re-exports: `SQSClient` (from `@aws-sdk/client-sqs`), `Job`, `Queue` (from `bullmq`) — exposed so consumers don't need to add direct deps to access types/values. + +### Framework constructs added + +- **Module augmentation** of `@prefabs.tech/fastify-config`'s `ApiConfig` interface — adds `worker: WorkerConfig`, so `fastify.config.worker` is type-safe everywhere downstream. +- **Module augmentation** of `fastify`'s `FastifyInstance` — adds `worker: JobOrchestrator`. +- **`fastify-plugin`-wrapped plugin** — `FastifyPlugin(plugin)` so the decoration leaks out of its encapsulation context and is visible on the parent instance. +- **Instance decorator** — `fastify.decorate("worker", jobOrchestrator)`. +- **`onClose` hook** — async hook that logs `"Shutting down worker"` and awaits `jobOrchestrator.shutdown()`. + +### Hooks / lifecycle registrations + +- `fastify.addHook("onClose", ...)` — drains cron scheduler and shuts down every queue adapter when the Fastify instance closes. +- BullMQ `worker.on("error", ...)` — invokes `config.onError(error)` if provided. +- BullMQ `worker.on("failed", ...)` — invokes `config.onFailed(job, error)` if both the callback is provided **and** `job` is truthy (BullMQ may emit `failed` with `null` job in some scenarios). + +### Conditional branches / feature flags / defaults + +- **Plugin registration skipped** when `fastify.config.worker` is undefined (logs `"Worker configuration is missing. Skipping plugin registration"` at `warn`). +- `JobOrchestrator.start()`: cron loop runs only if `config.cronJobs` is truthy; queue loop runs only if `config.queues` is truthy. Both are optional. +- `createQueueAdapter`: throws if the per-provider config block is missing or the provider is unknown. +- `BullMQAdapter` constructor: `workerOptions` default to `{ connection: queueOptions.connection, ...config.workerOptions }` — caller can override every field including `connection`. +- `BullMQAdapter.start()`: `onError` and `onFailed` listeners are always attached, but only forward the event when the respective callback is provided in config. +- `SQSAdapter.startPolling()`: early-return if `isPolling` is already true — idempotent. +- `SQSAdapter.poll()`: + - Default `WaitTimeSeconds: 20` is set **before** `...this.config.receiveMessageOptions`, so user-supplied `WaitTimeSeconds` wins. + - Resets `consecutiveErrors = 0` after each successful receive. + - Only iterates over `response.Messages` when it is non-empty. + - After an error, calls `onError` if provided, then sleeps `computeBackoffMs(consecutiveErrors)` — but **only if** `isPolling` is still true (so shutdown is not delayed by a backoff sleep). +- `SQSAdapter.processMessage()`: + - Throws explicitly if `message.Body` is `undefined`/`null` ("SQS message has no Body"); parse errors include the original message in the `onError` callback. + - Parse failure: route to `onError`, **do not** delete the message → it will be redelivered after visibility timeout. + - Handler failure: route to `onError`, **do not** delete the message → same redelivery behaviour. + - Handler success: send `DeleteMessageCommand` with the message's `ReceiptHandle`. +- `SQSAdapter.shutdown()`: flips `isPolling = false`, awaits `pollPromise` (swallowing errors — they were already surfaced via `onError`), then `client.destroy()`. Guarded with optional chaining so a never-started adapter shuts down cleanly. +- `SQSAdapter.computeBackoffMs(attempt)`: `min(500 * 2^(attempt-1), 8000) + random()*capped*0.25`. The 25% jitter is added on top of the cap (so the actual max delay is ~10 s, not 8 s). +- `SQSAdapter.push()` / `BullMQAdapter.push()`: wrap thrown errors with package-specific message strings; otherwise return the underlying message/job ID via non-null assertion. + +### Default values (we set them) + +- `SQSAdapter.DEFAULT_WAIT_TIME_SECONDS = 20` (long-polling default). +- `SQSAdapter.POLL_ERROR_BASE_DELAY_MS = 500`. +- `SQSAdapter.POLL_ERROR_MAX_DELAY_MS = 8000`. +- Backoff jitter factor = `0.25` (added to capped delay). +- `BullMQAdapter` `workerOptions.connection` defaults to `queueOptions.connection`. +- `QueueProvider` enum string values: `"sqs"`, `"bullmq"`. + +### Completeness checklist + +- [x] Classified every public export as "ours" or "theirs". +- [x] Listed every framework construct added (module augmentation, plugin wrapping, decorator, hook). +- [x] Identified every conditional branch (missing config skip, optional callbacks, idempotent polling, error-path no-delete behaviour, default-then-spread option ordering). +- [x] Documented default values (poll wait, backoff base/max/jitter, worker connection fallback, enum values). +- [x] Produced passthrough classification for every wrapped dependency (`node-cron`, `bullmq`, `@aws-sdk/client-sqs`). diff --git a/packages/worker/FEATURES.md b/packages/worker/FEATURES.md new file mode 100644 index 000000000..fea1410bd --- /dev/null +++ b/packages/worker/FEATURES.md @@ -0,0 +1,69 @@ + + +# FEATURES — `@prefabs.tech/fastify-worker` + +## Fastify plugin and lifecycle + +1. Default export is wrapped with [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) so `fastify.worker` is visible outside the encapsulation boundary. +2. If `fastify.config.worker` is missing, logs a warning (`"Worker configuration is missing..."`) and returns without decorating or registering hooks. +3. When `worker` config is present: logs `"Registering worker plugin"`, instantiates `JobOrchestrator` with `config.worker`, awaits `start()`, decorates Fastify with `worker: JobOrchestrator`. +4. Registers an `onClose` hook that logs `"Shutting down worker"` and awaits `jobOrchestrator.shutdown()`. + +## TypeScript module augmentation + +5. Declares `@prefabs.tech/fastify-config` → `interface ApiConfig { worker: WorkerConfig }`. +6. Declares `fastify` → `interface FastifyInstance { worker: JobOrchestrator }`. + +## Job orchestrator + +7. `JobOrchestrator` constructor creates a `CronScheduler` and empty `AdapterRegistry`. +8. `start()` loops `config.cronJobs` (when present) and schedules each via `cron.schedule(...)`. +9. `start()` loops `config.queues` (when present), runs `createQueueAdapter`, awaits `adapter.start()`, then `adapters.add(adapter)`. +10. `shutdown()` calls `cron.stopAll()` then `adapters.shutdownAll()`. + +## Cron scheduler + +11. Internal list of scheduled tasks for bulk lifecycle (`stopAll` clears tracking after stopping). +12. `schedule(job)` forwards `expression`, async `task`, and optional `options` to [`node-cron`](https://www.npmjs.com/package/node-cron) `schedule`; does not expose per-task handles. +13. `stopAll()` stops every tracked task then resets the list. + +## Queue adapter registry and factory + +14. `AdapterRegistry` indexes adapters by `adapter.queueName`; `add` overwrites existing name. +15. `get(name)`, `has(name)`, `remove(name)`, `getAll()` for registry access. +16. `shutdownAll()` awaits each adapter’s `shutdown()` in iteration order, then clears the map (after all complete). +17. `createQueueAdapter(config)` selects implementation by `config.provider`; throws `"BullMQ configuration is required for queue: …"` when `BULLMQ` without `bullmqConfig`. +18. `createQueueAdapter` throws `"SQS configuration is required for queue: …"` when `SQS` without `sqsConfig`. +19. Unknown `QueueProvider` value throws `"Unsupported queue provider: …"`. + +## Unified queue abstraction + +20. Abstract `QueueAdapter` requires `queueName`, `start()`, `shutdown()`, `getClient()`, and `push(data, options?)` returning `Promise` (job/message id semantics per adapter). + +## BullMQ adapter additions + +21. Builds `workerOptions` as `{ connection: queueOptions.connection, ...config.workerOptions }` so Worker shares Queue Redis connection unless overridden. +22. `Worker` invokes user `handler`; job forwarded as `Job`. +23. Registers listener on worker `error` that calls optional `config.onError(error)` when provided. +24. Registers listener on worker `failed` that calls optional `config.onFailed(job, error)` only when callback exists and `job` is truthy. +25. `push` passes `jobsOptions` through to BullMQ’s `queue.add`; **job name** is fixed to adapter’s `queueName` (caller cannot rename per `add`). +26. `push` wraps failures with `Error(\`Failed to push job to BullMQ queue: ${queueName}. Error: ${message}\`)`. + +## SQS adapter additions + +27. `ReceiveMessageCommand` input is `{ QueueUrl, WaitTimeSeconds: 20, ...receiveMessageOptions }` so defaults long-polling but allows override via `receiveMessageOptions`. +28. `startPolling()` is idempotent: no-op when already polling (`isPolling`). +29. Poll loop invokes `ReceiveMessageCommand` repeatedly while `isPolling`; successful receive resets consecutive error counter. +30. After `ReceiveMessageCommand` fails: optional `onError`; if still polling, delays with backoff `computeBackoffMs(consecutiveErrors)`. +31. Backoff formula: capped exponential delay from base 500 ms doubling per consecutive error up to max 8000 ms, plus up to **25% of the capped delay** random jitter (`capped + random() * capped * 0.25`). +32. Non-empty batches: parallel `Promise.all` over messages; each routed through private `processMessage`. +33. `processMessage`: rejects missing/null `Body` with error surfaced via optional `onError(error, message)`. +34. Body parsed with `JSON.parse` as `Payload`; parse failures call `onError` with context and **do not delete** message (implicit redelivery after visibility expires). +35. Successful handler run calls `DeleteMessageCommand` using `queueUrl` and message `ReceiptHandle`. +36. Handler throws: optional `onError(error, message)`; message **not deleted** → redelivery. +37. `shutdown`: sets `isPolling` false; awaits inflight `pollPromise` (errors swallowed—they were surfaced in loop); then `client?.destroy()` for clean teardown. +38. `push`: `SendMessageCommand` with `QueueUrl`, `MessageBody: JSON.stringify(data)`, spread `options`; wraps failures with `"Failed to push job to SQS queue: …"` message. + +## Re-exports + +39. Package re-exports `SQSClient` from `@aws-sdk/client-sqs` and `Job`, `Queue` from `bullmq` for consumers relying on upstream types/helpers without declaring those peer deps separately. diff --git a/packages/worker/GUIDE.md b/packages/worker/GUIDE.md new file mode 100644 index 000000000..006d4572d --- /dev/null +++ b/packages/worker/GUIDE.md @@ -0,0 +1,403 @@ +# `@prefabs.tech/fastify-worker` — Developer Guide + +## Installation + +### For package consumers + +```bash +npm install @prefabs.tech/fastify-worker @prefabs.tech/fastify-config +``` + +Optional peers (install for the providers you use): + +```bash +npm install bullmq +npm install @aws-sdk/client-sqs +``` + +```bash +pnpm add @prefabs.tech/fastify-worker @prefabs.tech/fastify-config +``` + +```bash +pnpm add bullmq +pnpm add @aws-sdk/client-sqs +``` + +### For monorepo development + +```bash +pnpm install +pnpm --filter @prefabs.tech/fastify-worker test +pnpm --filter @prefabs.tech/fastify-worker build +``` + +## Setup + +Register [`@prefabs.tech/fastify-config`](https://www.npmjs.com/package/@prefabs.tech/fastify-config) **before** `@prefabs.tech/fastify-worker` so `fastify.config.worker` exists. Optionally install `bullmq` and/or `@aws-sdk/client-sqs` for those queue providers. + +**All Fastify-centric examples below assume:** + +```typescript +import configPlugin from "@prefabs.tech/fastify-config"; +import workerPlugin from "@prefabs.tech/fastify-worker"; +import Fastify from "fastify"; + +import type { ApiConfig } from "@prefabs.tech/fastify-config"; +import { QueueProvider } from "@prefabs.tech/fastify-worker"; + +const config: ApiConfig = { + // …other application config required by ApiConfig shape… + worker: { + cronJobs: [], + queues: [], + }, +}; + +const fastify = Fastify({ logger: true }); + +await fastify.register(configPlugin, { config }); +await fastify.register(workerPlugin); +``` + +If `config.worker` is omitted at runtime, the worker plugin logs a warning and does **not** decorate `fastify.worker`; see [Fastify plugin and lifecycle](#fastify-plugin-and-lifecycle). + +--- + +## Base Libraries + +### [`node-cron`](https://www.npmjs.com/package/node-cron) — Modified + +We schedule jobs through `cron.schedule` with your expression, async task, and optional `TaskOptions`, but never return individual handles—only bulk `stopAll()` clears everything. + +→ **Their docs:** [node-cron](https://www.npmjs.com/package/node-cron) + +We change nothing about `schedule` inputs; we add internal task tracking plus wiring into `JobOrchestrator` shutdown. + +**What we add on top:** `CronScheduler` that stores tasks and exposes `schedule` / `stopAll` coordinated with orchestrator lifecycle. + +--- + +### [`bullmq`](https://www.npmjs.com/package/bullmq) — Modified + +We expose a single path: construct `Queue` + `Worker` from your options, forward `JobsOptions` through `push`, and surface only **`error`** and **`failed`** as optional callbacks. We fix the BullMQ job name to the configured queue adapter name (`queue.add(queueName, data, opts)`). + +→ **Their docs:** [BullMQ](https://bullmq.io/) · [npm](https://www.npmjs.com/package/bullmq) + +**What changes vs using BullMQ directly:** + +- `workerOptions` defaults **`connection`** from `queueOptions.connection` unless you override. +- No built-in forwarding of other worker events (`completed`, `progress`, …) — only optional `onError` / `onFailed`. +- `push` resolves to the added job **id string** wrapped on failure. + +**What we add on top:** uniform `QueueAdapter` API, orchestrator/registry integration, lifecycle `start` / `shutdown`, and consistent error wording on enqueue failure. + +--- + +### [`@aws-sdk/client-sqs`](https://www.npmjs.com/package/@aws-sdk/client-sqs) — Modified + +Consumer shape is **`SQSAdapterConfig`**, not raw SDK primitives: we build a long-polling receive loop with defaults, backoff, JSON bodies, parallel batch handling, and delete-on-success. + +→ **Their docs:** [AWS SDK v3 — SQS](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/sqs/) · [npm](https://www.npmjs.com/package/@aws-sdk/client-sqs) + +**What changes:** + +- Default **`WaitTimeSeconds: 20`**, overwritten if you pass it in `receiveMessageOptions`. +- Receive errors trigger exponential backoff (~500 ms doubling, cap 8000 ms + jitter) instead of spinning. +- **`Body`**: JSON only; invalid/missing body → optional `onError`, message **left** on queue (no delete). +- Handler error → optional `onError`, message **not deleted** → redelivery. +- Handler success → `DeleteMessageCommand`. +- Shutdown waits for inflight polling before `destroy()`. + +**What we add on top:** resilient poll loop + ack semantics unified with BullMQ behind `push`/`shutdown`. + +--- + +## Features + +### Fastify plugin and lifecycle + +The plugin is `fastify-plugin`-wrapped so `decorate("worker")` propagates correctly. Missing `fastify.config.worker` → **`warn`** and skip (no decorator, no `onClose` hook). + +```typescript +// When worker exists on config — after configPlugin: +await fastify.register(workerPlugin); +// fastify.worker is JobOrchestrator; onClose awaits orchestrator.shutdown(). + +// When worker is absent at runtime: +await fastify.register(workerPlugin); +// Logs: Worker configuration is missing. Skipping plugin registration +// (no decorator, no onClose hook registered by this plugin) +``` + +### Type augmentation + +Importing `@prefabs.tech/fastify-worker` augments **`ApiConfig.worker`** and **`FastifyInstance.worker`** so downstream code stays typed: + +```typescript +import type { FastifyInstance } from "fastify"; + +const routeHandler = async (fastify: FastifyInstance) => { + const q = fastify.worker.adapters.get("emails"); // WorkerConfig drove creation +}; +``` + +### `JobOrchestrator`: standalone vs Fastify + +Same class powers the plugin internally; you may run workers without Fastify: + +```typescript +import { JobOrchestrator } from "@prefabs.tech/fastify-worker"; + +const orchestrator = new JobOrchestrator({ + cronJobs: [{ expression: "* * * * *", task: async () => {} }], +}); + +await orchestrator.start(); +await orchestrator.shutdown(); +``` + +### Cron scheduling + +Each `cronJobs` entry is `{ expression, task, options?: TaskOptions }`. All tasks are tracked and stopped in `CronScheduler.stopAll()` (called from `JobOrchestrator.shutdown()`): + +```typescript +const config: ApiConfig = { + // … + worker: { + cronJobs: [ + { + expression: "0 9 * * 1", + task: async () => { + // weekly work + }, + options: { timezone: "America/New_York" }, + }, + ], + queues: [], + }, +}; +``` + +### Adapter registry & factory + +Queues are keyed by **`name`** (also used as BullMQ job name string). Providers are selected with `QueueProvider`; missing provider-specific blocks throw deterministic errors: + +```typescript +worker: { + queues: [ + { + name: "mail", + provider: QueueProvider.BULLMQ, + bullmqConfig: { + queueOptions: { connection: { host: "127.0.0.1", port: 6379 } }, + handler: async (_job) => {}, + }, + }, + { + name: "ingest", + provider: QueueProvider.SQS, + sqsConfig: { + queueUrl: "https://sqs.region.amazonaws.com/queue", + clientConfig: { region: "us-east-1" }, + handler: async (_payload) => {}, + }, + }, + ], +}; +``` + +### Enqueueing from handlers + +Both adapters expose `push` on the unified base class; generics narrow payload typing: + +```typescript +type Payload = { to: string }; + +const enqueue = async (fastify: FastifyInstance, data: Payload) => { + const mail = fastify.worker.adapters.get("mail"); + const id = await mail?.push(data, { attempts: 3 }); + return id; +}; +``` + +`AdapterRegistry.shutdownAll()` runs adapter shutdown sequentially across all registered adapters (cleared after completion). + +### BullMQ adapter nuances + +Share Redis between queue and worker by default; diverge explicitly in `workerOptions`: + +```typescript +bullmqConfig: { + queueOptions: { + connection: { host: "127.0.0.1", port: 6379 }, + }, + workerOptions: { + // connection inherited from queueOptions.connection unless overridden + concurrency: 5, + }, + handler: async (job) => { + // job typed as bullmq Job via generic if you propagate types + }, + onError: (err) => fastify.log.error(err), + onFailed: (_job, err) => fastify.log.error(err), +}; +``` + +Enqueue options pass through BullMQ `JobsOptions`: + +```typescript +await mail?.push({ id: "1" }, { delay: 5_000, removeOnComplete: true }); +``` + +### SQS adapter: receive, backoff, overrides + +Tune long polling and batch size via `receiveMessageOptions` (your values win over the default **`WaitTimeSeconds: 20`**): + +```typescript +sqsConfig: { + queueUrl: "https://sqs.region.amazonaws.com/queue", + clientConfig: { region: "us-east-1" }, + receiveMessageOptions: { + MaxNumberOfMessages: 5, + WaitTimeSeconds: 5, + VisibilityTimeout: 60, + }, + handler: async (payload: { sku: string }) => { + // success → adapter deletes message + }, + onError: (err, maybeMessage) => { + console.error(err, maybeMessage?.MessageId); + }, +}, +``` + +Enqueue spreads optional send overrides (advanced / FIFO attributes go here): + +```typescript +await ingest?.push({ sku: "A" }, { MessageGroupId: "group-a" }); // FIFO example +``` + +### Re-exports + +You may import allied symbols without duplicating peers in **`package.json`** if your bundler aligns versions: + +```typescript +import { Job, Queue, SQSClient } from "@prefabs.tech/fastify-worker"; +``` + +Prefer declaring matching optional peer deps in your app when relying on SDK/BullMQ at runtime. + +--- + +## Use Cases + +### HTTP API plus BullMQ-backed workers + +You run one Fastify app with config-driven queues so routes enqueue work and the same process consumes Redis jobs via `Worker`. + +```typescript +const config: ApiConfig = { + worker: { + queues: [ + { + name: "reports", + provider: QueueProvider.BULLMQ, + bullmqConfig: { + queueOptions: { connection: { host: "127.0.0.1", port: 6379 } }, + handler: async (job) => { + // generate report(job.data.reportId) + }, + }, + }, + ], + }, +} as ApiConfig; // cast if your full config is built elsewhere + +await fastify.register(configPlugin, { config }); +await fastify.register(workerPlugin); + +fastify.post("/reports", async (request, reply) => { + const id = await fastify.worker.adapters.get("reports")?.push({ + reportId: request.body.id, + }); + return reply.send({ jobId: id }); +}); +``` + +### Cron maintenance + SQS ingestion + +Combine scheduled tasks with an SQS consumer that parallelizes each receive batch and backs off on AWS errors. + +```typescript +const config: ApiConfig = { + worker: { + cronJobs: [ + { + expression: "0 * * * *", + task: async () => { + // hourly cleanup + }, + }, + ], + queues: [ + { + name: "events", + provider: QueueProvider.SQS, + sqsConfig: { + queueUrl: process.env.SQS_URL!, + clientConfig: { region: "us-east-1" }, + receiveMessageOptions: { MaxNumberOfMessages: 10 }, + handler: async (evt) => { + // process evt + }, + onError: (err) => console.error(err), + }, + }, + ], + }, +} as ApiConfig; +``` + +### Dedicated worker process (no HTTP) + +Run `JobOrchestrator` in a script that only processes background work. + +```typescript +import { JobOrchestrator, QueueProvider } from "@prefabs.tech/fastify-worker"; + +const orchestrator = new JobOrchestrator({ + queues: [ + { + name: "batch", + provider: QueueProvider.BULLMQ, + bullmqConfig: { + queueOptions: { connection: { host: "127.0.0.1", port: 6379 } }, + handler: async (job) => { + // process job.data + }, + }, + }, + ], +}); + +await orchestrator.start(); + +process.on("SIGINT", async () => { + await orchestrator.shutdown(); + process.exit(0); +}); +``` + +### Typed queue lookup + +Use `get` so `push` and handlers stay aligned on the same shape. + +```typescript +type EmailJob = { to: string; subject: string }; + +const sendLater = async (fastify: FastifyInstance, job: EmailJob) => { + const q = fastify.worker.adapters.get("email"); + return q?.push(job, { attempts: 2 }); +}; +``` diff --git a/packages/worker/README.md b/packages/worker/README.md index 6e5e6ffb6..5b8adb61b 100644 --- a/packages/worker/README.md +++ b/packages/worker/README.md @@ -4,31 +4,51 @@ A [Fastify](https://github.com/fastify/fastify) plugin for managing queue proces ## Features -- **Cron Jobs**: Schedule recurring tasks using standard cron expressions -- **Queue System**: Queue management with support for BullMQ and AWS SQS -- **BullMQ Integration**: Redis-based message queues for high-performance background processing -- **AWS SQS Integration**: Support for Amazon Simple Queue Service +- **Cron Jobs**: Schedule recurring tasks using standard cron expressions (powered by [`node-cron`](https://www.npmjs.com/package/node-cron)) +- **Queue System**: Pluggable adapter registry with support for BullMQ and AWS SQS +- **BullMQ Integration**: Redis-based message queues for high-performance background processing (powered by [`bullmq`](https://www.npmjs.com/package/bullmq)) +- **AWS SQS Integration**: Support for Amazon Simple Queue Service with long-polling and exponential backoff (powered by [`@aws-sdk/client-sqs`](https://www.npmjs.com/package/@aws-sdk/client-sqs)) +- **Standalone or Fastify**: Use the orchestrator with Fastify (via the plugin) or directly in a non-Fastify process ## Requirements -- [@prefabs.tech/fastify-config](https://www.npmjs.com/package/@prefabs.tech/fastify-config) + +**Peer dependencies** (install separately): + +- [`fastify`](https://www.npmjs.com/package/fastify) `>=5.2.2` +- [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) `>=5.0.1` +- [`@prefabs.tech/fastify-config`](https://www.npmjs.com/package/@prefabs.tech/fastify-config) — provides `fastify.config` which this plugin reads `config.worker` from + +**Optional peer dependencies** (install only the providers you use): + +- [`bullmq`](https://www.npmjs.com/package/bullmq) — required if you configure any `BULLMQ` queues +- [`@aws-sdk/client-sqs`](https://www.npmjs.com/package/@aws-sdk/client-sqs) — required if you configure any `SQS` queues + +## Installation + +```bash +npm install @prefabs.tech/fastify-worker @prefabs.tech/fastify-config +# plus the providers you need: +npm install bullmq +npm install @aws-sdk/client-sqs +``` ## Usage -### Fastify Plugin +### Fastify plugin -Register the worker plugin with your Fastify instance: +Register `@prefabs.tech/fastify-config` first (so `fastify.config.worker` is available), then register the worker plugin: ```typescript +import configPlugin from "@prefabs.tech/fastify-config"; import workerPlugin from "@prefabs.tech/fastify-worker"; import Fastify from "fastify"; import config from "./config"; const start = async () => { - const fastify = Fastify({ - logger: config.logger, - }); + const fastify = Fastify({ logger: config.logger }); + await fastify.register(configPlugin, { config }); await fastify.register(workerPlugin); await fastify.listen({ @@ -40,47 +60,58 @@ const start = async () => { start(); ``` -### Pushing to the queue +The plugin: -The `AdapterRegistry` is a singleton. Once the plugin initializes the worker, any service can access the same registry directly — no instance passing required: +1. Reads `fastify.config.worker` (see [Configuration](#configuration)). If missing, it logs a warning and skips registration. +2. Creates a `JobOrchestrator` instance, starts cron jobs, and starts queue adapters. +3. Decorates the Fastify instance with `fastify.worker` (typed as `JobOrchestrator`). +4. Drains all adapters on the `onClose` hook. -```typescript -await fastify.register(workerPlugin); -``` +### Accessing queues from your services + +The plugin decorates the Fastify instance with `fastify.worker`. Inside any route or service that has the Fastify instance, use the per-instance registry: ```typescript -import { JobOrchestrator } from "@prefabs.tech/fastify-worker"; +import type { FastifyInstance } from "fastify"; -const queue = JobOrchestrator.adapters.get("queue-name") +export const enqueueHello = async (fastify: FastifyInstance) => { + const queue = fastify.worker.adapters.get("bull-queue"); -if (queue) { - queue.push({ message: 'Hello world!' }) -} + if (queue) { + await queue.push({ message: "Hello world!" }); + } +}; ``` -The plugin creates the `JobOrchestrator` instance, which populates `JobOrchestrator.adapters` on `start()`. Services import `JobOrchestrator` and access the static registry directly. On fastify close, `jobOrchestrator.shutdown()` drains all adapters. +### Standalone (without Fastify) -### Standalone - -Use the `JobOrchestrator` class directly without Fastify: +Use `JobOrchestrator` directly when you don't have a Fastify instance: ```typescript import { JobOrchestrator } from "@prefabs.tech/fastify-worker"; -const jobOrchestrator = new JobOrchestrator({ - cronJobs: [...], - queues: [...], +const orchestrator = new JobOrchestrator({ + cronJobs: [ + /* ... */ + ], + queues: [ + /* ... */ + ], }); -await jobOrchestrator.start(); +await orchestrator.start(); + +const queue = orchestrator.adapters.get("bull-queue"); -// later... -await jobOrchestrator.shutdown(); +await queue?.push({ message: "Hello from a standalone worker" }); + +// On process shutdown: +await orchestrator.shutdown(); ``` ## Configuration -Add worker configuration to your config: +Add a `worker` block to your `ApiConfig`: ```typescript import { QueueProvider } from "@prefabs.tech/fastify-worker"; @@ -107,7 +138,7 @@ const config: ApiConfig = { provider: QueueProvider.BULLMQ, bullmqConfig: { handler: async (job) => { - // + // process the job }, queueOptions: { connection: { @@ -130,7 +161,7 @@ const config: ApiConfig = { region: "", }, handler: async (message) => { - // + // process the message }, queueUrl: "", }, @@ -139,3 +170,32 @@ const config: ApiConfig = { }, }; ``` + +### SQS long-polling + +The SQS adapter uses **long-polling by default** (`WaitTimeSeconds: 20`) to avoid tight CPU loops and minimise empty receives. Override it explicitly via `receiveMessageOptions`: + +```typescript +sqsConfig: { + // ... + receiveMessageOptions: { + QueueUrl: "https://sqs.us-east-1.amazonaws.com/.../my-queue", + MaxNumberOfMessages: 10, + WaitTimeSeconds: 5, + }, +} +``` + +The poll loop also applies an exponential backoff (capped at ~8s) when `ReceiveMessageCommand` fails, so a transient AWS outage will not turn into a request storm. + +### Typed payloads + +`BullMQAdapter` and `SQSAdapter` (and the registry lookups) are generic over the payload type. Specify a payload type when retrieving the adapter to get type-safe `push` and handler signatures: + +```typescript +type EmailJob = { to: string; subject: string }; + +const queue = fastify.worker.adapters.get("email-queue"); + +await queue?.push({ to: "user@example.com", subject: "Welcome" }); +``` diff --git a/packages/worker/package.json b/packages/worker/package.json index 9dd95bd71..e179de8c2 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@prefabs.tech/fastify-worker", - "version": "0.93.4", + "version": "0.94.0", "description": "Fastify worker plugin", "homepage": "https://github.com/prefabs-tech/fastify/tree/main/packages/worker#readme", "repository": { @@ -31,30 +31,38 @@ "typecheck": "tsc --noEmit -p tsconfig.json --composite false" }, "dependencies": { - "@aws-sdk/client-sqs": "3.991.0", - "bullmq": "5.69.3", - "node-cron": "4.2.1", - "zod": "3.25.76" + "node-cron": "4.2.1" }, "devDependencies": { - "@prefabs.tech/eslint-config": "0.5.0", - "@prefabs.tech/fastify-config": "0.93.5", - "@prefabs.tech/tsconfig": "0.5.0", + "@aws-sdk/client-sqs": "3.991.0", + "@prefabs.tech/eslint-config": "0.7.0", + "@prefabs.tech/fastify-config": "0.94.0", + "@prefabs.tech/tsconfig": "0.7.0", + "@types/node": "24.10.15", "@vitest/coverage-istanbul": "3.2.4", - "eslint": "9.39.2", - "fastify": "5.7.4", + "bullmq": "5.69.3", + "eslint": "9.39.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "prettier": "3.8.1", - "supertokens-node": "14.1.4", + "prettier": "3.8.3", "typescript": "5.9.3", - "vite": "6.4.1", + "vite": "6.4.2", "vitest": "3.2.4" }, "peerDependencies": { - "@prefabs.tech/fastify-config": "0.93.5", - "fastify": ">=5.2.1", - "fastify-plugin": ">=5.0.1", - "supertokens-node": ">=14.1.3" + "@aws-sdk/client-sqs": ">=3.991.0", + "@prefabs.tech/fastify-config": "0.94.0", + "bullmq": ">=5.69.3", + "fastify": ">=5.2.2", + "fastify-plugin": ">=5.0.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-sqs": { + "optional": true + }, + "bullmq": { + "optional": true + } }, "engines": { "node": ">=20" diff --git a/packages/worker/src/__test__/cron/scheduler.test.ts b/packages/worker/src/__test__/cron/scheduler.test.ts index 53617eda0..538c2d2da 100644 --- a/packages/worker/src/__test__/cron/scheduler.test.ts +++ b/packages/worker/src/__test__/cron/scheduler.test.ts @@ -2,11 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import CronScheduler from "../../cron/scheduler"; -const { mockStop, mockSchedule } = vi.hoisted(() => { +const { mockSchedule, mockStop } = vi.hoisted(() => { const mockStop = vi.fn(); const mockSchedule = vi.fn().mockReturnValue({ stop: mockStop }); - return { mockStop, mockSchedule }; + return { mockSchedule, mockStop }; }); vi.mock("node-cron", () => ({ @@ -37,7 +37,7 @@ describe("CronScheduler", () => { it("should pass options to node-cron when provided", () => { const task = vi.fn(); const options = { scheduled: true, timezone: "UTC" }; - const job = { expression: "0 * * * *", task, options }; + const job = { expression: "0 * * * *", options, task }; scheduler.schedule(job); diff --git a/packages/worker/src/__test__/jobOrchestrator.test.ts b/packages/worker/src/__test__/jobOrchestrator.test.ts index 89d6d9c08..f784be3e1 100644 --- a/packages/worker/src/__test__/jobOrchestrator.test.ts +++ b/packages/worker/src/__test__/jobOrchestrator.test.ts @@ -1,16 +1,16 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { QueueProvider } from "../enum"; import JobOrchestrator from "../jobOrchestrator"; -const { mockSchedule, mockStopAll, mockAdapterStart, mockAdapterShutdown } = +const { mockAdapterShutdown, mockAdapterStart, mockSchedule, mockStopAll } = vi.hoisted(() => ({ - mockSchedule: vi.fn(), - mockStopAll: vi.fn(), - // eslint-disable-next-line unicorn/no-useless-undefined - mockAdapterStart: vi.fn().mockResolvedValue(undefined), // eslint-disable-next-line unicorn/no-useless-undefined mockAdapterShutdown: vi.fn().mockResolvedValue(undefined), + // eslint-disable-next-line unicorn/no-useless-undefined + mockAdapterStart: vi.fn().mockResolvedValue(undefined), + mockSchedule: vi.fn(), + mockStopAll: vi.fn(), })); vi.mock("../cron", () => ({ @@ -28,11 +28,11 @@ vi.mock("../queue", async (importOriginal) => { createQueueAdapter: vi .fn() .mockImplementation((config: { name: string }) => ({ - queueName: config.name, - start: mockAdapterStart, - shutdown: mockAdapterShutdown, getClient: vi.fn(), push: vi.fn(), + queueName: config.name, + shutdown: mockAdapterShutdown, + start: mockAdapterStart, })), }; }); @@ -48,11 +48,6 @@ describe("JobOrchestrator", () => { mockAdapterShutdown.mockResolvedValue(undefined); }); - afterEach(async () => { - // Clear static registry between tests to prevent state leakage - await JobOrchestrator.adapters.shutdownAll(); - }); - describe("constructor", () => { it("should create a CronScheduler instance", async () => { const { CronScheduler } = vi.mocked(await import("../cron")); @@ -62,6 +57,13 @@ describe("JobOrchestrator", () => { expect(CronScheduler).toHaveBeenCalledOnce(); expect(orchestrator.cron).toBeDefined(); }); + + it("should create a per-instance AdapterRegistry", () => { + orchestrator = new JobOrchestrator({ cronJobs: [], queues: [] }); + + expect(orchestrator.adapters).toBeDefined(); + expect(orchestrator.adapters.getAll()).toEqual([]); + }); }); describe("start", () => { @@ -117,7 +119,7 @@ describe("JobOrchestrator", () => { expect(mockAdapterStart).toHaveBeenCalledTimes(2); }); - it("should register adapters in the static registry", async () => { + it("should register adapters in the per-instance registry", async () => { orchestrator = new JobOrchestrator({ queues: [ { @@ -133,7 +135,7 @@ describe("JobOrchestrator", () => { await orchestrator.start(); - expect(JobOrchestrator.adapters.has("my-queue")).toBe(true); + expect(orchestrator.adapters.has("my-queue")).toBe(true); }); it("should not schedule any cron jobs when cronJobs is undefined", async () => { @@ -202,7 +204,50 @@ describe("JobOrchestrator", () => { await orchestrator.start(); await orchestrator.shutdown(); - expect(JobOrchestrator.adapters.getAll()).toHaveLength(0); + expect(orchestrator.adapters.getAll()).toHaveLength(0); + }); + + it("should not affect another instance's adapters when one shuts down", async () => { + const first = new JobOrchestrator({ + queues: [ + { + bullmqConfig: { + handler: vi.fn(), + queueOptions: { connection: {} }, + }, + name: "queue-first", + provider: QueueProvider.BULLMQ, + }, + ], + }); + const second = new JobOrchestrator({ + queues: [ + { + bullmqConfig: { + handler: vi.fn(), + queueOptions: { connection: {} }, + }, + name: "queue-second", + provider: QueueProvider.BULLMQ, + }, + ], + }); + + await first.start(); + await second.start(); + await first.shutdown(); + + expect(first.adapters.has("queue-first")).toBe(false); + expect(second.adapters.has("queue-second")).toBe(true); + }); + }); + + describe("instance isolation", () => { + it("should give each instance its own AdapterRegistry", () => { + const first = new JobOrchestrator({ cronJobs: [], queues: [] }); + const second = new JobOrchestrator({ cronJobs: [], queues: [] }); + + expect(first.adapters).not.toBe(second.adapters); }); }); }); diff --git a/packages/worker/src/__test__/plugin.integration.test.ts b/packages/worker/src/__test__/plugin.integration.test.ts new file mode 100644 index 000000000..d61de22b9 --- /dev/null +++ b/packages/worker/src/__test__/plugin.integration.test.ts @@ -0,0 +1,42 @@ +import type { ApiConfig } from "@prefabs.tech/fastify-config"; + +import fastify from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +import JobOrchestrator from "../jobOrchestrator"; + +describe("Worker plugin integration", () => { + let api: ReturnType | undefined; + + afterEach(async () => { + await api?.close().catch(() => {}); + api = undefined; + }); + + it("decorates Fastify with a live JobOrchestrator when worker config exists", async () => { + const { default: plugin } = await import("../plugin"); + + api = fastify({ logger: false }); + api.decorate("config", { + worker: { cronJobs: [], queues: [] }, + } as ApiConfig as never); + + await api.register(plugin); + await api.ready(); + + expect(api.worker).toBeInstanceOf(JobOrchestrator); + await api.close(); + }); + + it("does not add the worker decorator when worker config is missing", async () => { + const { default: plugin } = await import("../plugin"); + + api = fastify({ logger: false }); + api.decorate("config", {} as ApiConfig as never); + + await api.register(plugin); + await api.ready(); + + expect("worker" in api).toBe(false); + }); +}); diff --git a/packages/worker/src/__test__/plugin.test.ts b/packages/worker/src/__test__/plugin.test.ts index 952a94e77..b82881fc2 100644 --- a/packages/worker/src/__test__/plugin.test.ts +++ b/packages/worker/src/__test__/plugin.test.ts @@ -1,9 +1,9 @@ +import type { FastifyInstance } from "fastify"; + import fastify from "fastify"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { FastifyInstance } from "fastify"; - -const { mockStart, mockShutdown, MockJobOrchestrator } = vi.hoisted(() => { +const { MockJobOrchestrator, mockShutdown, mockStart } = vi.hoisted(() => { // eslint-disable-next-line unicorn/no-useless-undefined const mockStart = vi.fn().mockResolvedValue(undefined); // eslint-disable-next-line unicorn/no-useless-undefined @@ -14,7 +14,7 @@ const { mockStart, mockShutdown, MockJobOrchestrator } = vi.hoisted(() => { start: mockStart, })); - return { mockStart, mockShutdown, MockJobOrchestrator }; + return { MockJobOrchestrator, mockShutdown, mockStart }; }); vi.mock("../jobOrchestrator", () => ({ @@ -44,21 +44,27 @@ describe("Worker plugin", async () => { await api.close().catch(() => {}); }); - it("should log a warning and skip registration when worker config is missing", async () => { + it("warns when worker configuration is missing and skips registration", async () => { + const warnSpy = vi.spyOn(api.log, "warn"); api.decorate("config", {} as never); await api.register(plugin); await api.ready(); + expect(warnSpy).toHaveBeenCalledWith( + "Worker configuration is missing. Skipping plugin registration", + ); expect(MockJobOrchestrator).not.toHaveBeenCalled(); }); it("should create a JobOrchestrator and call start when worker config is present", async () => { + const infoSpy = vi.spyOn(api.log, "info"); api.decorate("config", { worker: workerConfig } as never); await api.register(plugin); await api.ready(); + expect(infoSpy).toHaveBeenCalledWith("Registering worker plugin"); expect(MockJobOrchestrator).toHaveBeenCalledWith(workerConfig); expect(mockStart).toHaveBeenCalledOnce(); }); @@ -73,12 +79,14 @@ describe("Worker plugin", async () => { }); it("should call shutdown on the orchestrator when fastify closes", async () => { + const infoSpy = vi.spyOn(api.log, "info"); api.decorate("config", { worker: workerConfig } as never); await api.register(plugin); await api.ready(); await api.close(); + expect(infoSpy).toHaveBeenCalledWith("Shutting down worker"); expect(mockShutdown).toHaveBeenCalledOnce(); }); }); diff --git a/packages/worker/src/__test__/queue/adapterRegistry.test.ts b/packages/worker/src/__test__/queue/adapterRegistry.test.ts index 5782dcf84..5e57b38ca 100644 --- a/packages/worker/src/__test__/queue/adapterRegistry.test.ts +++ b/packages/worker/src/__test__/queue/adapterRegistry.test.ts @@ -4,12 +4,12 @@ import AdapterRegistry from "../../queue/adapterRegistry"; import QueueAdapter from "../../queue/adapters/base"; class MockAdapter extends QueueAdapter { - // eslint-disable-next-line unicorn/no-useless-undefined - start = vi.fn().mockResolvedValue(undefined); - // eslint-disable-next-line unicorn/no-useless-undefined - shutdown = vi.fn().mockResolvedValue(undefined); getClient = vi.fn().mockReturnValue({}); push = vi.fn().mockResolvedValue("job-id"); + // eslint-disable-next-line unicorn/no-useless-undefined + shutdown = vi.fn().mockResolvedValue(undefined); + // eslint-disable-next-line unicorn/no-useless-undefined + start = vi.fn().mockResolvedValue(undefined); } describe("AdapterRegistry", () => { diff --git a/packages/worker/src/__test__/queue/adapters/bullmq.test.ts b/packages/worker/src/__test__/queue/adapters/bullmq.test.ts index 804cbcdf5..d21d9cb40 100644 --- a/packages/worker/src/__test__/queue/adapters/bullmq.test.ts +++ b/packages/worker/src/__test__/queue/adapters/bullmq.test.ts @@ -4,14 +4,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import BullMQAdapter from "../../../queue/adapters/bullmq"; const { - mockQueueAdd, - mockQueueClose, - mockWorkerClose, - mockWorkerOn, capturedHandler, eventListeners, MockQueue, + mockQueueAdd, + mockQueueClose, MockWorker, + mockWorkerClose, + mockWorkerOn, } = vi.hoisted(() => { const mockQueueAdd = vi.fn().mockResolvedValue({ id: "job-123" }); // eslint-disable-next-line unicorn/no-useless-undefined @@ -42,17 +42,17 @@ const { .mockImplementation( (_name: string, handler: (job: unknown) => Promise) => { capturedHandler.fn = handler; - return { on: mockWorkerOn, close: mockWorkerClose }; + return { close: mockWorkerClose, on: mockWorkerOn }; }, ); return { - MockQueue, - MockWorker, capturedHandler, eventListeners, + MockQueue, mockQueueAdd, mockQueueClose, + MockWorker, mockWorkerClose, mockWorkerOn, }; @@ -101,22 +101,20 @@ describe("BullMQAdapter", () => { ); }); - it("should merge workerOptions with connection from queueOptions", async () => { + it("prefers workerOptions.connection when it differs from queue connection", async () => { + const otherConnection = { host: "other-host", port: 6380 }; const config = { ...baseConfig, - workerOptions: { - concurrency: 5, - connection: baseConfig.queueOptions.connection, - }, + workerOptions: { concurrency: 2, connection: otherConnection }, }; - const adapterWithWorkerOptions = new BullMQAdapter("test-queue", config); + const overrideAdapter = new BullMQAdapter("override-queue", config); - await adapterWithWorkerOptions.start(); + await overrideAdapter.start(); expect(MockWorker).toHaveBeenCalledWith( - "test-queue", + "override-queue", expect.any(Function), - { connection: baseConfig.queueOptions.connection, concurrency: 5 }, + { concurrency: 2, connection: otherConnection }, ); }); @@ -240,5 +238,18 @@ describe("BullMQAdapter", () => { eventListeners["failed"]({ id: "job-1" }, new Error("error")), ).not.toThrow(); }); + + it("should not invoke onFailed when failed event emits without a job", async () => { + const onFailed = vi.fn(); + const adapterWithFailed = new BullMQAdapter("test-queue", { + ...baseConfig, + onFailed, + }); + await adapterWithFailed.start(); + + eventListeners["failed"](undefined, new Error("no job")); + + expect(onFailed).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/worker/src/__test__/queue/adapters/sqs.test.ts b/packages/worker/src/__test__/queue/adapters/sqs.test.ts index 72538095b..3a5d9f849 100644 --- a/packages/worker/src/__test__/queue/adapters/sqs.test.ts +++ b/packages/worker/src/__test__/queue/adapters/sqs.test.ts @@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import SQSAdapter from "../../../queue/adapters/sqs"; -const { mockClientSend, mockClientDestroy, MockSQSClient } = vi.hoisted(() => { +const { mockClientDestroy, mockClientSend, MockSQSClient } = vi.hoisted(() => { const mockClientSend = vi.fn(); const mockClientDestroy = vi.fn(); const MockSQSClient = vi.fn().mockImplementation(() => ({ @@ -15,7 +15,7 @@ const { mockClientSend, mockClientDestroy, MockSQSClient } = vi.hoisted(() => { send: mockClientSend, })); - return { mockClientSend, mockClientDestroy, MockSQSClient }; + return { mockClientDestroy, mockClientSend, MockSQSClient }; }); vi.mock("@aws-sdk/client-sqs", () => { @@ -81,12 +81,22 @@ describe("SQSAdapter", () => { it("should send a ReceiveMessageCommand once polling starts", async () => { await adapter.start(); - // poll() calls send() synchronously before its first await expect(mockClientSend).toHaveBeenCalledWith( expect.any(ReceiveMessageCommand), ); }); + it("should default WaitTimeSeconds to 20 (long-poll) when not provided", async () => { + await adapter.start(); + + const callArgument = mockClientSend.mock + .calls[0][0] as ReceiveMessageCommand; + expect(callArgument.input).toMatchObject({ + QueueUrl: baseConfig.queueUrl, + WaitTimeSeconds: 20, + }); + }); + it("should include custom receiveMessageOptions in the ReceiveMessageCommand", async () => { const configWithOptions = { ...baseConfig, @@ -106,20 +116,71 @@ describe("SQSAdapter", () => { QueueUrl: baseConfig.queueUrl, }); }); + + it("should allow the caller to override the default WaitTimeSeconds", async () => { + const customAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + receiveMessageOptions: { + QueueUrl: baseConfig.queueUrl, + WaitTimeSeconds: 5, + }, + }); + + await customAdapter.start(); + + const callArgument = mockClientSend.mock + .calls[0][0] as ReceiveMessageCommand; + expect(callArgument.input).toMatchObject({ WaitTimeSeconds: 5 }); + }); }); describe("shutdown", () => { it("should set isPolling to false and destroy the client", async () => { - await adapter.start(); - await adapter.shutdown(); + // Use an immediately-resolving send so the in-flight poll iteration + // can complete (shutdown now awaits the in-flight poll before destroying). + const shutdownAdapter = new SQSAdapter("sqs-queue", baseConfig); + mockClientSend.mockImplementation(async () => ({})); + + await shutdownAdapter.start(); + await shutdownAdapter.shutdown(); - expect(adapter["isPolling"]).toBe(false); + expect(shutdownAdapter["isPolling"]).toBe(false); expect(mockClientDestroy).toHaveBeenCalledOnce(); }); it("should not throw if called before start", async () => { await expect(adapter.shutdown()).resolves.not.toThrow(); }); + + it("should await the in-flight poll iteration before destroying the client", async () => { + const events: string[] = []; + let resolveInFlight: ((value: unknown) => void) | undefined; + + mockClientSend.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInFlight = resolve; + }), + ); + mockClientDestroy.mockImplementation(() => { + events.push("destroy"); + }); + + const drainAdapter = new SQSAdapter("drain-queue", baseConfig); + await drainAdapter.start(); + + const shutdownPromise = drainAdapter.shutdown(); + + // Destroy must not have fired yet — the poll iteration is still in flight. + expect(events).toEqual([]); + + events.push("resolve-in-flight"); + resolveInFlight?.({}); + + await shutdownPromise; + + expect(events).toEqual(["resolve-in-flight", "destroy"]); + }); }); describe("getClient", () => { @@ -134,7 +195,6 @@ describe("SQSAdapter", () => { describe("push", () => { it("should send a SendMessageCommand and return the message id", async () => { await adapter.start(); - // The poll loop is suspended on neverResolve — this once-value goes to push mockClientSend.mockResolvedValueOnce({ MessageId: "msg-abc-123" }); const id = await adapter.push({ key: "value" }); @@ -156,7 +216,7 @@ describe("SQSAdapter", () => { await adapter.push( { key: "value" }, - { MessageGroupId: "group-1", MessageDeduplicationId: "dedup-1" }, + { MessageDeduplicationId: "dedup-1", MessageGroupId: "group-1" }, ); const sendCall = mockClientSend.mock.calls.find( @@ -180,7 +240,6 @@ describe("SQSAdapter", () => { describe("polling", () => { it("should call the handler and delete the message when a message is received", async () => { - // Create the adapter first so we can reference it inside the mock const pollingAdapter = new SQSAdapter("sqs-queue", baseConfig); let sendCallCount = 0; mockClientSend.mockImplementation(async () => { @@ -192,7 +251,6 @@ describe("SQSAdapter", () => { ], }; } - // After first receive + delete, stop the loop pollingAdapter["isPolling"] = false; return {}; }); @@ -249,31 +307,202 @@ describe("SQSAdapter", () => { ...baseConfig, onError, }); + + mockClientSend.mockImplementation(async () => { + errorAdapter["isPolling"] = false; + throw new Error("SQS network error"); + }); + + await errorAdapter.start(); + await waitFor(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "SQS network error" }), + ); + }); + + it("wraps non-Error rejects from ReceiveMessage in an Error passed to onError", async () => { + const onError = vi.fn(); + const stringErrorAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + onError, + }); + + mockClientSend.mockImplementation(async () => { + stringErrorAdapter["isPolling"] = false; + throw "plain-string-throw"; + }); + + await stringErrorAdapter.start(); + await waitFor(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "plain-string-throw" }), + ); + }); + + it("processes multiple received messages concurrently in one batch", async () => { + const handler = vi.fn().mockResolvedValue(); + const batchAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + handler, + }); let sendCallCount = 0; mockClientSend.mockImplementation(async () => { sendCallCount++; if (sendCallCount === 1) { - throw new Error("SQS network error"); + return { + Messages: [ + { Body: '{"a":1}', ReceiptHandle: "receipt-handle-a" }, + { Body: '{"b":2}', ReceiptHandle: "receipt-handle-b" }, + ], + }; } - errorAdapter["isPolling"] = false; - return { Messages: [] }; + batchAdapter["isPolling"] = false; + + return {}; }); - await errorAdapter.start(); + await batchAdapter.start(); + await waitFor(50); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith({ a: 1 }); + expect(handler).toHaveBeenCalledWith({ b: 2 }); + + const deleteCalls = mockClientSend.mock.calls.filter( + (call) => call[0] instanceof DeleteMessageCommand, + ); + expect(deleteCalls).toHaveLength(2); + }); + + it("wraps handler rejections that are not Error instances before onError", async () => { + const onError = vi.fn(); + const badRejectAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + handler: vi.fn().mockRejectedValueOnce("not-an-error-object"), + onError, + }); + let sendCallCount = 0; + + mockClientSend.mockImplementation(async () => { + sendCallCount++; + if (sendCallCount === 1) { + return { + Messages: [ + { Body: '{"key":"value"}', ReceiptHandle: "receipt-handle-1" }, + ], + }; + } + badRejectAdapter["isPolling"] = false; + + return {}; + }); + + await badRejectAdapter.start(); await waitFor(); expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: "SQS network error" }), + expect.objectContaining({ + message: "not-an-error-object", + }), + expect.objectContaining({ ReceiptHandle: "receipt-handle-1" }), ); }); + it("should call onError with a parse error when message Body is not valid JSON", async () => { + const onError = vi.fn(); + const parseAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + onError, + }); + let sendCallCount = 0; + + mockClientSend.mockImplementation(async () => { + sendCallCount++; + if (sendCallCount === 1) { + return { + Messages: [{ Body: "not-json", ReceiptHandle: "receipt-handle-1" }], + }; + } + parseAdapter["isPolling"] = false; + return {}; + }); + + await parseAdapter.start(); + await waitFor(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Failed to parse SQS message body"), + }), + expect.objectContaining({ ReceiptHandle: "receipt-handle-1" }), + ); + expect(baseConfig.handler).not.toHaveBeenCalled(); + }); + + it("should call onError when the message Body is missing", async () => { + const onError = vi.fn(); + const missingBodyAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + onError, + }); + let sendCallCount = 0; + + mockClientSend.mockImplementation(async () => { + sendCallCount++; + if (sendCallCount === 1) { + return { + Messages: [{ ReceiptHandle: "receipt-handle-1" }], + }; + } + missingBodyAdapter["isPolling"] = false; + return {}; + }); + + await missingBodyAdapter.start(); + await waitFor(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Failed to parse SQS message body"), + }), + expect.objectContaining({ ReceiptHandle: "receipt-handle-1" }), + ); + }); + + it("should back off (sleep) between iterations after receive errors", async () => { + const onError = vi.fn(); + const backoffAdapter = new SQSAdapter("sqs-queue", { + ...baseConfig, + onError, + }); + + const sendTimes: number[] = []; + mockClientSend.mockImplementation(async () => { + sendTimes.push(Date.now()); + if (sendTimes.length >= 2) { + backoffAdapter["isPolling"] = false; + return {}; + } + throw new Error("transient"); + }); + + await backoffAdapter.start(); + // Backoff base delay is 500ms; allow enough time for two iterations. + await waitFor(900); + + expect(sendTimes.length).toBeGreaterThanOrEqual(2); + // The second send should occur after the configured base backoff (500ms), + // less a small fudge factor for scheduling jitter. + expect(sendTimes[1] - sendTimes[0]).toBeGreaterThanOrEqual(450); + }); + it("should not start a second polling loop if already polling", async () => { await adapter.start(); - // Calling startPolling again while isPolling=true should be a no-op adapter["startPolling"](); - // Only the initial ReceiveMessageCommand should have been dispatched expect(mockClientSend).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/worker/src/enum/index.ts b/packages/worker/src/enum/index.ts index eeee861ce..e7d89cd8e 100644 --- a/packages/worker/src/enum/index.ts +++ b/packages/worker/src/enum/index.ts @@ -1,4 +1,4 @@ export enum QueueProvider { - SQS = "sqs", BULLMQ = "bullmq", + SQS = "sqs", } diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 264c4af2f..edd5cbd2b 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -1,5 +1,7 @@ import "@prefabs.tech/fastify-config"; +import type JobOrchestrator from "./jobOrchestrator"; + import { WorkerConfig } from "./types"; declare module "@prefabs.tech/fastify-config" { @@ -8,12 +10,18 @@ declare module "@prefabs.tech/fastify-config" { } } -export { SQSClient } from "@aws-sdk/client-sqs"; -export { Job, Queue } from "bullmq"; +declare module "fastify" { + interface FastifyInstance { + worker: JobOrchestrator; + } +} -export { default } from "./plugin"; +export * from "./enum"; export { default as JobOrchestrator } from "./jobOrchestrator"; -export * from "./enum"; +export { default } from "./plugin"; export * from "./queue"; + export * from "./types"; +export { SQSClient } from "@aws-sdk/client-sqs"; +export { Job, Queue } from "bullmq"; diff --git a/packages/worker/src/jobOrchestrator.ts b/packages/worker/src/jobOrchestrator.ts index cfd35aef9..98093d327 100644 --- a/packages/worker/src/jobOrchestrator.ts +++ b/packages/worker/src/jobOrchestrator.ts @@ -3,13 +3,19 @@ import { AdapterRegistry, createQueueAdapter } from "./queue"; import { WorkerConfig } from "./types"; class JobOrchestrator { - public static readonly adapters = new AdapterRegistry(); + public readonly adapters: AdapterRegistry; public readonly cron: CronScheduler; private config: WorkerConfig; constructor(config: WorkerConfig) { this.config = config; this.cron = new CronScheduler(); + this.adapters = new AdapterRegistry(); + } + + async shutdown(): Promise { + this.cron.stopAll(); + await this.adapters.shutdownAll(); } async start(): Promise { @@ -24,15 +30,10 @@ class JobOrchestrator { const adapter = createQueueAdapter(queueConfig); await adapter.start(); - JobOrchestrator.adapters.add(adapter); + this.adapters.add(adapter); } } } - - async shutdown(): Promise { - this.cron.stopAll(); - await JobOrchestrator.adapters.shutdownAll(); - } } export default JobOrchestrator; diff --git a/packages/worker/src/queue/adapterRegistry.ts b/packages/worker/src/queue/adapterRegistry.ts index 5ab83412e..bb1ce37af 100644 --- a/packages/worker/src/queue/adapterRegistry.ts +++ b/packages/worker/src/queue/adapterRegistry.ts @@ -7,8 +7,8 @@ class AdapterRegistry { this.adapters.set(adapter.queueName, adapter); } - get(name: string): QueueAdapter | undefined { - return this.adapters.get(name); + get(name: string): QueueAdapter | undefined { + return this.adapters.get(name) as QueueAdapter | undefined; } getAll(): QueueAdapter[] { diff --git a/packages/worker/src/queue/adapters/base.ts b/packages/worker/src/queue/adapters/base.ts index 845e7d9cd..84a49d219 100644 --- a/packages/worker/src/queue/adapters/base.ts +++ b/packages/worker/src/queue/adapters/base.ts @@ -5,13 +5,13 @@ abstract class QueueAdapter { this.queueName = name; } - abstract start(): Promise; - abstract shutdown(): Promise; abstract getClient(): unknown; abstract push( data: Payload, options?: Record, ): Promise; + abstract shutdown(): Promise; + abstract start(): Promise; } export default QueueAdapter; diff --git a/packages/worker/src/queue/adapters/bullmq.ts b/packages/worker/src/queue/adapters/bullmq.ts index 6dd3a3192..99cae845e 100644 --- a/packages/worker/src/queue/adapters/bullmq.ts +++ b/packages/worker/src/queue/adapters/bullmq.ts @@ -1,30 +1,30 @@ import { Queue as BullQueue, - Worker, Job, + JobsOptions, QueueOptions, + Worker, WorkerOptions, - JobsOptions, } from "bullmq"; import QueueAdapter from "./base"; -export interface BullMQAdapterConfig { +export interface BullMQAdapterConfig { + handler: (job: Job) => Promise; + onError?: (error: Error) => void; + onFailed?: (job: Job, error: Error) => void; queueOptions: QueueOptions; workerOptions?: WorkerOptions; - handler: (job: Job) => Promise; - onError?: (error: Error) => void; - onFailed?: (job: Job, error: Error) => void; } -class BullMQAdapter extends QueueAdapter { +class BullMQAdapter extends QueueAdapter { public queue?: BullQueue; public worker?: Worker; - private config: BullMQAdapterConfig; + private config: BullMQAdapterConfig; private queueOptions: QueueOptions; private workerOptions: WorkerOptions; - constructor(name: string, config: BullMQAdapterConfig) { + constructor(name: string, config: BullMQAdapterConfig) { super(name); this.config = config; @@ -35,12 +35,33 @@ class BullMQAdapter extends QueueAdapter { }; } + getClient(): BullQueue { + return this.queue!; + } + + async push(data: Payload, options?: JobsOptions): Promise { + try { + const job = await this.queue!.add(this.queueName, data, options); + + return job.id!; + } catch (error) { + throw new Error( + `Failed to push job to BullMQ queue: ${this.queueName}. Error: ${(error as Error).message}`, + ); + } + } + + async shutdown(): Promise { + await this.worker?.close(); + await this.queue?.close(); + } + async start(): Promise { this.queue = new BullQueue(this.queueName, this.queueOptions); this.worker = new Worker( this.queueName, - async (job: Job) => { - await this.config.handler(job); + async (job: Job) => { + await this.config.handler(job as Job); }, this.workerOptions, ); @@ -52,32 +73,11 @@ class BullMQAdapter extends QueueAdapter { }); this.worker.on("failed", (job, error) => { - if (this.config.onFailed) { + if (this.config.onFailed && job) { this.config.onFailed(job as Job, error); } }); } - - async shutdown(): Promise { - await this.worker?.close(); - await this.queue?.close(); - } - - getClient(): BullQueue { - return this.queue!; - } - - async push(data: Payload, options?: JobsOptions): Promise { - try { - const job = await this.queue!.add(this.queueName, data, options); - - return job.id!; - } catch (error) { - throw new Error( - `Failed to push job to BullMQ queue: ${this.queueName}. Error: ${(error as Error).message}`, - ); - } - } } export default BullMQAdapter; diff --git a/packages/worker/src/queue/adapters/index.ts b/packages/worker/src/queue/adapters/index.ts index 266871156..208924c6e 100644 --- a/packages/worker/src/queue/adapters/index.ts +++ b/packages/worker/src/queue/adapters/index.ts @@ -1,6 +1,6 @@ export { default as QueueAdapter } from "./base"; export { default as BullMQAdapter } from "./bullmq"; -export { default as SQSAdapter } from "./sqs"; - export type { BullMQAdapterConfig } from "./bullmq"; + +export { default as SQSAdapter } from "./sqs"; export type { SQSAdapterConfig } from "./sqs"; diff --git a/packages/worker/src/queue/adapters/sqs.ts b/packages/worker/src/queue/adapters/sqs.ts index 4a58a5e06..7a1d274f5 100644 --- a/packages/worker/src/queue/adapters/sqs.ts +++ b/packages/worker/src/queue/adapters/sqs.ts @@ -10,114 +10,178 @@ import { import QueueAdapter from "./base"; -export interface SQSAdapterConfig { +export interface SQSAdapterConfig { clientConfig: SQSClientConfig; - handler: (data: unknown) => Promise; + handler: (data: Payload) => Promise; onError?: (error: Error, message?: Message) => void; queueUrl: string; receiveMessageOptions?: ReceiveMessageCommandInput; } -class SQSAdapter extends QueueAdapter { - private config: SQSAdapterConfig; +const DEFAULT_WAIT_TIME_SECONDS = 20; +const POLL_ERROR_BASE_DELAY_MS = 500; +const POLL_ERROR_MAX_DELAY_MS = 8000; + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +class SQSAdapter extends QueueAdapter { public client?: SQSClient; - private queueUrl: string; + private config: SQSAdapterConfig; private isPolling: boolean = false; + private pollPromise?: Promise; + private queueUrl: string; - constructor(name: string, config: SQSAdapterConfig) { + constructor(name: string, config: SQSAdapterConfig) { super(name); this.config = config; this.queueUrl = config.queueUrl; } - async start(): Promise { - this.client = new SQSClient(this.config.clientConfig); - this.startPolling(); + getClient(): SQSClient { + return this.client!; + } + + async push( + data: Payload, + options?: Record, + ): Promise { + try { + const command = new SendMessageCommand({ + MessageBody: JSON.stringify(data), + QueueUrl: this.queueUrl, + ...options, + }); + + const response = await this.client!.send(command); + + return response.MessageId!; + } catch (error) { + throw new Error( + `Failed to push job to SQS queue: ${this.queueName}. Error: ${error instanceof Error ? error.message : String(error)}`, + ); + } } async shutdown(): Promise { this.isPolling = false; + + // Wait for the in-flight poll iteration to finish before destroying the + // underlying client. This avoids "client destroyed" errors from in-flight + // SDK calls and gives in-progress handlers a chance to complete. + if (this.pollPromise) { + try { + await this.pollPromise; + } catch { + // Errors are already surfaced via onError inside the poll loop. + } + } + this.client?.destroy(); } - getClient(): SQSClient { - return this.client!; + async start(): Promise { + this.client = new SQSClient(this.config.clientConfig); + this.startPolling(); } - private startPolling(): void { - if (this.isPolling) { - return; - } + private computeBackoffMs(attempt: number): number { + const exponential = POLL_ERROR_BASE_DELAY_MS * 2 ** (attempt - 1); + const capped = Math.min(exponential, POLL_ERROR_MAX_DELAY_MS); + const jitter = Math.random() * capped * 0.25; - this.isPolling = true; - this.poll(); + return capped + jitter; } private async poll(): Promise { + let consecutiveErrors = 0; + while (this.isPolling) { try { const command = new ReceiveMessageCommand({ QueueUrl: this.queueUrl, + WaitTimeSeconds: DEFAULT_WAIT_TIME_SECONDS, ...this.config.receiveMessageOptions, }); const response = await this.client!.send(command); + consecutiveErrors = 0; if (response.Messages && response.Messages.length > 0) { await Promise.all( - response.Messages.map(async (message: Message) => { - try { - const data = JSON.parse(message.Body ?? "{}") as Payload; - - await this.config.handler(data); - - await this.client!.send( - new DeleteMessageCommand({ - QueueUrl: this.queueUrl, - ReceiptHandle: message.ReceiptHandle, - }), - ); - } catch (error) { - if (this.config.onError) { - this.config.onError( - error instanceof Error ? error : new Error(String(error)), - message, - ); - } - } - }), + response.Messages.map((message: Message) => + this.processMessage(message), + ), ); } } catch (error) { + consecutiveErrors++; if (this.config.onError) { this.config.onError( error instanceof Error ? error : new Error(String(error)), ); } + + if (this.isPolling) { + await sleep(this.computeBackoffMs(consecutiveErrors)); + } } } } - async push( - data: Payload, - options?: Record, - ): Promise { - try { - const command = new SendMessageCommand({ - QueueUrl: this.queueUrl, - MessageBody: JSON.stringify(data), - ...options, - }); + private async processMessage(message: Message): Promise { + let data: Payload; - const response = await this.client!.send(command); + try { + if (message.Body === undefined || message.Body === null) { + throw new Error("SQS message has no Body"); + } - return response.MessageId!; + data = JSON.parse(message.Body) as Payload; } catch (error) { - throw new Error( - `Failed to push job to SQS queue: ${this.queueName}. Error: ${error instanceof Error ? error.message : String(error)}`, + if (this.config.onError) { + this.config.onError( + new Error( + `Failed to parse SQS message body: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + message, + ); + } + + return; + } + + try { + await this.config.handler(data); + + await this.client!.send( + new DeleteMessageCommand({ + QueueUrl: this.queueUrl, + ReceiptHandle: message.ReceiptHandle, + }), ); + } catch (error) { + if (this.config.onError) { + this.config.onError( + error instanceof Error ? error : new Error(String(error)), + message, + ); + } + } + } + + private startPolling(): void { + if (this.isPolling) { + return; } + + this.isPolling = true; + this.pollPromise = this.poll(); } } diff --git a/packages/worker/src/queue/factory.ts b/packages/worker/src/queue/factory.ts index 517d017c0..555410032 100644 --- a/packages/worker/src/queue/factory.ts +++ b/packages/worker/src/queue/factory.ts @@ -1,8 +1,10 @@ import { QueueProvider } from "../enum"; import { QueueConfig } from "../types"; -import { QueueAdapter, BullMQAdapter, SQSAdapter } from "./adapters"; +import { BullMQAdapter, QueueAdapter, SQSAdapter } from "./adapters"; -const createQueueAdapter = (config: QueueConfig): QueueAdapter => { +const createQueueAdapter = ( + config: QueueConfig, +): QueueAdapter => { switch (config.provider) { case QueueProvider.BULLMQ: { if (!config.bullmqConfig) { @@ -11,7 +13,7 @@ const createQueueAdapter = (config: QueueConfig): QueueAdapter => { ); } - return new BullMQAdapter(config.name, config.bullmqConfig); + return new BullMQAdapter(config.name, config.bullmqConfig); } case QueueProvider.SQS: { @@ -21,7 +23,7 @@ const createQueueAdapter = (config: QueueConfig): QueueAdapter => { ); } - return new SQSAdapter(config.name, config.sqsConfig); + return new SQSAdapter(config.name, config.sqsConfig); } default: { diff --git a/packages/worker/src/queue/index.ts b/packages/worker/src/queue/index.ts index 241050086..6c122100b 100644 --- a/packages/worker/src/queue/index.ts +++ b/packages/worker/src/queue/index.ts @@ -1,4 +1,4 @@ -export * from "./adapters"; - export { default as AdapterRegistry } from "./adapterRegistry"; + +export * from "./adapters"; export { default as createQueueAdapter } from "./factory"; diff --git a/packages/worker/src/types/cron.ts b/packages/worker/src/types/cron.ts index c0d477013..7a9f9459c 100644 --- a/packages/worker/src/types/cron.ts +++ b/packages/worker/src/types/cron.ts @@ -2,6 +2,6 @@ import { TaskOptions } from "node-cron"; export interface CronJob { expression: string; - task: () => Promise; options?: TaskOptions; + task: () => Promise; } diff --git a/packages/worker/src/types/queue.ts b/packages/worker/src/types/queue.ts index fc3e1e0dd..db29f1bde 100644 --- a/packages/worker/src/types/queue.ts +++ b/packages/worker/src/types/queue.ts @@ -2,9 +2,9 @@ import { QueueProvider } from "../enum"; import { BullMQAdapterConfig } from "../queue/adapters/bullmq"; import { SQSAdapterConfig } from "../queue/adapters/sqs"; -export interface QueueConfig { - bullmqConfig?: BullMQAdapterConfig; +export interface QueueConfig { + bullmqConfig?: BullMQAdapterConfig; name: string; provider: QueueProvider; - sqsConfig?: SQSAdapterConfig; + sqsConfig?: SQSAdapterConfig; } diff --git a/packages/worker/vite.config.ts b/packages/worker/vite.config.ts index cad5f275e..6cbae02ec 100644 --- a/packages/worker/vite.config.ts +++ b/packages/worker/vite.config.ts @@ -1,6 +1,5 @@ -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; - import { defineConfig, loadEnv } from "vite"; import { dependencies, peerDependencies } from "./package.json"; @@ -27,8 +26,8 @@ export default defineConfig(({ mode }) => { globals: { "@prefabs.tech/fastify-error-handler": "PrefabsTechFastifyErrorHandler", - "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", "@prefabs.tech/fastify-graphql": "PrefabsTechFastifyGraphql", + "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", fastify: "Fastify", "fastify-plugin": "FastifyPlugin", mercurius: "mercurius", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb5d4e434..85fefbe75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,28 +4,38 @@ settings: autoInstallPeers: false excludeLinksFromLockfile: false +overrides: + '@typescript-eslint/eslint-plugin': 8.58.0 + '@typescript-eslint/parser': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0 + '@typescript-eslint/utils': 8.58.0 + fast-xml-parser: 5.7.1 + handlebars: 4.7.9 + protobufjs: 7.5.5 + typescript-eslint: 8.58.0 + importers: .: devDependencies: '@commitlint/cli': - specifier: 20.4.1 - version: 20.4.1(@types/node@24.10.13)(typescript@5.9.3) + specifier: 20.5.3 + version: 20.5.3(@types/node@24.10.15)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) '@commitlint/config-conventional': - specifier: 20.4.1 - version: 20.4.1 + specifier: 20.5.3 + version: 20.5.3 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 husky: specifier: 9.1.7 version: 9.1.7 shipjs: - specifier: 0.28.2 - version: 0.28.2(@types/node@24.10.13)(conventional-commits-filter@5.0.0) + specifier: 0.28.3 + version: 0.28.3(@types/node@24.10.15)(conventional-commits-filter@5.0.0) turbo: - specifier: 2.8.7 - version: 2.8.7 + specifier: 2.9.9 + version: 2.9.9 typescript: specifier: 5.9.3 version: 5.9.3 @@ -33,23 +43,23 @@ importers: packages/config: devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 @@ -57,17 +67,17 @@ importers: specifier: 8.21.0 version: 8.21.0 prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 typescript: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) packages/error-handler: dependencies: @@ -75,97 +85,100 @@ importers: specifier: 6.0.4 version: 6.0.4 stacktracey: - specifier: 2.1.8 - version: 2.1.8 + specifier: 2.2.0 + version: 2.2.0 devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@types/stack-trace': specifier: 0.0.33 version: 0.0.33 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 typescript: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) packages/firebase: dependencies: firebase-admin: - specifier: 13.6.1 - version: 13.6.1 + specifier: 13.8.0 + version: 13.8.0 zod: specifier: 3.25.76 version: 3.25.76 devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../config '@prefabs.tech/fastify-error-handler': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../error-handler '@prefabs.tech/fastify-graphql': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../graphql '@prefabs.tech/fastify-slonik': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../slonik '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 graphql: - specifier: 16.12.0 - version: 16.12.0 + specifier: 16.13.2 + version: 16.13.2 mercurius: - specifier: 16.7.0 - version: 16.7.0(graphql@16.12.0) + specifier: 16.9.0 + version: 16.9.0(graphql@16.13.2) + pg-mem: + specifier: 3.0.14 + version: 3.0.14(slonik@46.8.0(zod@3.25.76)) prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 slonik: specifier: 46.8.0 version: 46.8.0(zod@3.25.76) @@ -176,57 +189,57 @@ importers: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) packages/graphql: dependencies: '@graphql-tools/merge': - specifier: 9.1.7 - version: 9.1.7(graphql@16.12.0) + specifier: 9.1.8 + version: 9.1.8(graphql@16.13.2) graphql-tag: specifier: 2.12.6 - version: 2.12.6(graphql@16.12.0) + version: 2.12.6(graphql@16.13.2) devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../config '@prefabs.tech/fastify-slonik': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../slonik '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 graphql: - specifier: 16.12.0 - version: 16.12.0 + specifier: 16.13.2 + version: 16.13.2 mercurius: - specifier: 16.7.0 - version: 16.7.0(graphql@16.12.0) + specifier: 16.9.0 + version: 16.9.0(graphql@16.13.2) prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 slonik: specifier: 46.8.0 version: 46.8.0(zod@3.25.76) @@ -234,11 +247,11 @@ importers: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) zod: specifier: 3.25.76 version: 3.25.76 @@ -262,35 +275,35 @@ importers: version: 1.6.0(nodemailer@7.0.13) devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../config '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@types/mjml': specifier: 4.7.4 version: 4.7.4 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@types/nodemailer': - specifier: 6.4.22 - version: 6.4.22 + specifier: 6.4.23 + version: 6.4.23 '@types/nodemailer-html-to-text': specifier: 3.1.3 version: 3.1.3 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 @@ -298,29 +311,29 @@ importers: specifier: 4.18.0 version: 4.18.0 prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 typescript: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) packages/s3: dependencies: '@aws-sdk/client-s3': - specifier: 3.989.0 - version: 3.989.0 + specifier: 3.1042.0 + version: 3.1042.0 '@aws-sdk/lib-storage': - specifier: 3.989.0 - version: 3.989.0(@aws-sdk/client-s3@3.989.0) + specifier: 3.1042.0 + version: 3.1042.0(@aws-sdk/client-s3@3.1042.0) '@aws-sdk/s3-request-presigner': - specifier: 3.989.0 - version: 3.989.0 + specifier: 3.1042.0 + version: 3.1042.0 '@fastify/multipart': specifier: 9.4.0 version: 9.4.0 @@ -331,57 +344,57 @@ importers: specifier: 9.0.8 version: 9.0.8 ajv: - specifier: 8.17.1 - version: 8.17.1 + specifier: 8.20.0 + version: 8.20.0 busboy: specifier: 1.6.0 version: 1.6.0 graphql-upload-minimal: specifier: 1.6.4 - version: 1.6.4(graphql@16.12.0) + version: 1.6.4(graphql@16.13.2) uuid: specifier: 9.0.1 version: 9.0.1 devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../config '@prefabs.tech/fastify-error-handler': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../error-handler '@prefabs.tech/fastify-graphql': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../graphql '@prefabs.tech/fastify-slonik': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../slonik '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 graphql: - specifier: 16.12.0 - version: 16.12.0 + specifier: 16.13.2 + version: 16.13.2 prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 slonik: specifier: 46.8.0 version: 46.8.0(zod@3.25.76) @@ -389,11 +402,11 @@ importers: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) zod: specifier: 3.25.76 version: 3.25.76 @@ -407,21 +420,21 @@ importers: specifier: 2.0.1 version: 2.0.1 pg: - specifier: 8.18.0 - version: 8.18.0 + specifier: 8.20.0 + version: 8.20.0 slonik-interceptor-query-logging: specifier: 46.8.0 version: 46.8.0(slonik@46.8.0(zod@3.25.76)) devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../config '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@slonik/driver': specifier: 46.8.0 version: 46.8.0(zod@3.25.76) @@ -429,29 +442,29 @@ importers: specifier: 2.0.6 version: 2.0.6 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@types/pg': - specifier: 8.16.0 - version: 8.16.0 + specifier: 8.20.0 + version: 8.20.0 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 pg-mem: - specifier: 3.0.12 - version: 3.0.12(slonik@46.8.0(zod@3.25.76)) + specifier: 3.0.14 + version: 3.0.14(slonik@46.8.0(zod@3.25.76)) prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 slonik: specifier: 46.8.0 version: 46.8.0(zod@3.25.76) @@ -459,11 +472,11 @@ importers: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) zod: specifier: 3.25.76 version: 3.25.76 @@ -474,42 +487,42 @@ importers: specifier: 9.7.0 version: 9.7.0 '@fastify/swagger-ui': - specifier: 5.2.5 - version: 5.2.5 + specifier: 5.2.6 + version: 5.2.6 devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 typescript: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) packages/user: dependencies: @@ -517,66 +530,66 @@ importers: specifier: 2.0.1 version: 2.0.1 validator: - specifier: 13.15.26 - version: 13.15.26 + specifier: 13.15.35 + version: 13.15.35 devDependencies: '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../config '@prefabs.tech/fastify-error-handler': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../error-handler '@prefabs.tech/fastify-graphql': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../graphql '@prefabs.tech/fastify-mailer': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../mailer '@prefabs.tech/fastify-s3': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../s3 '@prefabs.tech/fastify-slonik': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../slonik '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 '@types/humps': specifier: 2.0.6 version: 2.0.6 '@types/node': - specifier: 24.10.13 - version: 24.10.13 + specifier: 24.10.15 + version: 24.10.15 '@types/validator': specifier: 13.15.10 version: 13.15.10 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 graphql: - specifier: 16.12.0 - version: 16.12.0 + specifier: 16.13.2 + version: 16.13.2 mercurius: - specifier: 16.7.0 - version: 16.7.0(graphql@16.12.0) + specifier: 16.9.0 + version: 16.9.0(graphql@16.13.2) mercurius-auth: specifier: 6.0.0 version: 6.0.0 prettier: - specifier: 3.8.1 - version: 3.8.1 + specifier: 3.8.3 + version: 3.8.3 slonik: specifier: 46.8.0 version: 46.8.0(zod@3.25.76) @@ -587,66 +600,63 @@ importers: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) zod: specifier: 3.25.76 version: 3.25.76 packages/worker: dependencies: - '@aws-sdk/client-sqs': - specifier: 3.991.0 - version: 3.991.0 - bullmq: - specifier: 5.69.3 - version: 5.69.3 node-cron: specifier: 4.2.1 version: 4.2.1 - zod: - specifier: 3.25.76 - version: 3.25.76 devDependencies: + '@aws-sdk/client-sqs': + specifier: 3.991.0 + version: 3.991.0 '@prefabs.tech/eslint-config': - specifier: 0.5.0 - version: 0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3) + specifier: 0.7.0 + version: 0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3) '@prefabs.tech/fastify-config': - specifier: 0.93.5 + specifier: 0.94.0 version: link:../config '@prefabs.tech/tsconfig': - specifier: 0.5.0 - version: 0.5.0(@types/node@24.10.13) + specifier: 0.7.0 + version: 0.7.0 + '@types/node': + specifier: 24.10.15 + version: 24.10.15 '@vitest/coverage-istanbul': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) + bullmq: + specifier: 5.69.3 + version: 5.69.3 eslint: - specifier: 9.39.2 - version: 9.39.2(jiti@2.6.1) + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) fastify: - specifier: 5.7.4 - version: 5.7.4 + specifier: 5.8.5 + version: 5.8.5 fastify-plugin: specifier: 5.1.0 version: 5.1.0 prettier: - specifier: 3.8.1 - version: 3.8.1 - supertokens-node: - specifier: 14.1.4 - version: 14.1.4 + specifier: 3.8.3 + version: 3.8.3 typescript: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.4.1 - version: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + specifier: 6.4.2 + version: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) vitest: specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) packages: @@ -673,165 +683,153 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.989.0': - resolution: {integrity: sha512-ccz2miIetWAgrJYmKCpSnRjF8jew7DPstl54nufhfPMtM1MLxD2z55eSk1eJj3Umhu4CioNN1aY1ILT7fwlSiw==} + '@aws-sdk/client-s3@3.1042.0': + resolution: {integrity: sha512-z3Ibstr7ckDT10dz/nkk4+93LitrrO49Oq563/JoFHt30ZNodPBCfSxysKcelLyi/lNVF1MZrhZZfikUAG3iNQ==} engines: {node: '>=20.0.0'} '@aws-sdk/client-sqs@3.991.0': resolution: {integrity: sha512-7apQczqvynhNt4BRyMge+CuMLzQxSr8aj1DrKIk+YN0Qd4phiq8XGWDiclVEAyKfg7JUuYK6YIWoUYl3QdIkNg==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sso@3.990.0': - resolution: {integrity: sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/core@3.973.10': - resolution: {integrity: sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==} + '@aws-sdk/core@3.974.8': + resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} engines: {node: '>=20.0.0'} - '@aws-sdk/crc64-nvme@3.972.0': - resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==} + '@aws-sdk/crc64-nvme@3.972.7': + resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.8': - resolution: {integrity: sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==} + '@aws-sdk/credential-provider-env@3.972.34': + resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.10': - resolution: {integrity: sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==} + '@aws-sdk/credential-provider-http@3.972.36': + resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.8': - resolution: {integrity: sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==} + '@aws-sdk/credential-provider-ini@3.972.38': + resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.8': - resolution: {integrity: sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==} + '@aws-sdk/credential-provider-login@3.972.38': + resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.9': - resolution: {integrity: sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==} + '@aws-sdk/credential-provider-node@3.972.39': + resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.8': - resolution: {integrity: sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==} + '@aws-sdk/credential-provider-process@3.972.34': + resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.8': - resolution: {integrity: sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==} + '@aws-sdk/credential-provider-sso@3.972.38': + resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.8': - resolution: {integrity: sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==} + '@aws-sdk/credential-provider-web-identity@3.972.38': + resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.989.0': - resolution: {integrity: sha512-8pJXMJ7MT5At/5ANFC68IbhfG8hNe0/ISsbtdVopgQEsiZEAHr0HDNoPcyoRnc3RTzjykz7Q95uf/Lpz3PQNmA==} + '@aws-sdk/lib-storage@3.1042.0': + resolution: {integrity: sha512-LTmyMaSwFOLQ7geoYbCRDoc2R73Qbc1uLaAVHAdmzhINIB0JlP2TZ9N17t8BLgfB1zFD3bjQRThf/fGC0vvMbg==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.989.0 + '@aws-sdk/client-s3': ^3.1042.0 - '@aws-sdk/middleware-bucket-endpoint@3.972.3': - resolution: {integrity: sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==} + '@aws-sdk/middleware-bucket-endpoint@3.972.10': + resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-expect-continue@3.972.3': - resolution: {integrity: sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==} + '@aws-sdk/middleware-expect-continue@3.972.10': + resolution: {integrity: sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.972.8': - resolution: {integrity: sha512-Hn6gumcN/3/8Fzo9z7N1pA2PRfE8S+qAqdb4g3MqzXjIOIe+VxD7edO/DKAJ1YH11639EGQIHBz0wdOb5btjtw==} + '@aws-sdk/middleware-flexible-checksums@3.974.16': + resolution: {integrity: sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.3': - resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + '@aws-sdk/middleware-host-header@3.972.10': + resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-location-constraint@3.972.3': - resolution: {integrity: sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==} + '@aws-sdk/middleware-location-constraint@3.972.10': + resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.3': - resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + '@aws-sdk/middleware-logger@3.972.10': + resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.3': - resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + '@aws-sdk/middleware-recursion-detection@3.972.11': + resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.10': - resolution: {integrity: sha512-wLkB4bshbBtsAiC2WwlHzOWXu1fx3ftL63fQl0DxEda48Q6B8bcHydZppE3KjEIpPyiNOllByfSnb07cYpIgmw==} + '@aws-sdk/middleware-sdk-s3@3.972.37': + resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-sqs@3.972.7': - resolution: {integrity: sha512-DcJLYE4sRjgUyb2SupQGaRgBYc+j89N9nXeMT0PwwVvaBGmKqcxa7PFvz0kBnQrBckPWlfrPyyyMwOeT5BEp6Q==} + '@aws-sdk/middleware-sdk-sqs@3.972.22': + resolution: {integrity: sha512-DtR3mEiOUJcnEX/QuXmvbJto6xvQzp2ftnHb29c0aQYdmmzbKf0gsu9ovx1i/yy4ZR6m0rttTucS0iiP32dlGA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-ssec@3.972.3': - resolution: {integrity: sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==} + '@aws-sdk/middleware-ssec@3.972.10': + resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.10': - resolution: {integrity: sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==} + '@aws-sdk/middleware-user-agent@3.972.38': + resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.990.0': - resolution: {integrity: sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==} + '@aws-sdk/nested-clients@3.997.6': + resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.3': - resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + '@aws-sdk/region-config-resolver@3.972.13': + resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.989.0': - resolution: {integrity: sha512-cn5e6DODSDulkyGvVCM7E66kGNdyYkMEiSTwTVKWrrc+Vv19AZaytt2n+GARjGOzTsGy48BLW/jyp5iexfdB6g==} + '@aws-sdk/s3-request-presigner@3.1042.0': + resolution: {integrity: sha512-yWgXWDg4W0Vk1xlY4M7puM07ce6PPBS4tBytNOpu57k+wY0puXgxkGN0+k/dUAA4sR4Th6+wDps50gBBLj48Ew==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.989.0': - resolution: {integrity: sha512-rVhR/BUZdnru7tLlxWD+uzoKB1LAs2L0pcoh6rYgIYuCtQflnsC6Ud0SpfqIsOapBSBKXdoW73IITFf+XFMdCQ==} + '@aws-sdk/signature-v4-multi-region@3.996.25': + resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.990.0': - resolution: {integrity: sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==} + '@aws-sdk/token-providers@3.1041.0': + resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.914.0': - resolution: {integrity: sha512-kQWPsRDmom4yvAfyG6L1lMmlwnTzm1XwMHOU+G5IFlsP4YEaMtXidDzW/wiivY0QFrhfCz/4TVmu0a2aPU57ug==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/types@3.973.1': - resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + '@aws-sdk/types@3.973.8': + resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-arn-parser@3.972.2': - resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.989.0': - resolution: {integrity: sha512-eKmAOeQM4Qusq0jtcbZPiNWky8XaojByKC/n+THbJ8vJf7t4ys8LlcZ4PrBSHZISe9cC484mQsPVOQh6iySjqw==} + '@aws-sdk/util-endpoints@3.991.0': + resolution: {integrity: sha512-m8tcZ3SbqG3NRDv0Py3iBKdb4/FlpOCP4CQ6wRtsk4vs3UypZ0nFdZwCRVnTN7j+ldj+V72xVi/JBlxFBDE7Sg==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.990.0': - resolution: {integrity: sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==} + '@aws-sdk/util-endpoints@3.996.8': + resolution: {integrity: sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.991.0': - resolution: {integrity: sha512-m8tcZ3SbqG3NRDv0Py3iBKdb4/FlpOCP4CQ6wRtsk4vs3UypZ0nFdZwCRVnTN7j+ldj+V72xVi/JBlxFBDE7Sg==} + '@aws-sdk/util-format-url@3.972.10': + resolution: {integrity: sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-format-url@3.972.3': - resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.893.0': - resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==} - engines: {node: '>=18.0.0'} + '@aws-sdk/util-user-agent-browser@3.972.10': + resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} - '@aws-sdk/util-user-agent-browser@3.972.3': - resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} - - '@aws-sdk/util-user-agent-node@3.972.8': - resolution: {integrity: sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==} + '@aws-sdk/util-user-agent-node@3.973.24': + resolution: {integrity: sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==} engines: {node: '>=20.0.0'} peerDependencies: aws-crt: '>=1.0.0' @@ -839,18 +837,22 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.4': - resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} + '@aws-sdk/xml-builder@3.972.22': + resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} engines: {node: '>=20.0.0'} - '@aws/lambda-invoke-store@0.2.3': - resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.5': resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} engines: {node: '>=6.9.0'} @@ -918,73 +920,73 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@commitlint/cli@20.4.1': - resolution: {integrity: sha512-uuFKKpc7OtQM+6SRqT+a4kV818o1pS+uvv/gsRhyX7g4x495jg+Q7P0+O9VNGyLXBYP0syksS7gMRDJKcekr6A==} + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} engines: {node: '>=v18'} hasBin: true - '@commitlint/config-conventional@20.4.1': - resolution: {integrity: sha512-0YUvIeBtpi86XriqrR+TCULVFiyYTIOEPjK7tTRMxjcBm1qlzb+kz7IF2WxL6Fq5DaundG8VO37BNgMkMTBwqA==} + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} engines: {node: '>=v18'} - '@commitlint/config-validator@20.4.0': - resolution: {integrity: sha512-zShmKTF+sqyNOfAE0vKcqnpvVpG0YX8F9G/ZIQHI2CoKyK+PSdladXMSns400aZ5/QZs+0fN75B//3Q5CHw++w==} + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} engines: {node: '>=v18'} - '@commitlint/ensure@20.4.1': - resolution: {integrity: sha512-WLQqaFx1pBooiVvBrA1YfJNFqZF8wS/YGOtr5RzApDbV9tQ52qT5VkTsY65hFTnXhW8PcDfZLaknfJTmPejmlw==} + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} engines: {node: '>=v18'} '@commitlint/execute-rule@20.0.0': resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} engines: {node: '>=v18'} - '@commitlint/format@20.4.0': - resolution: {integrity: sha512-i3ki3WR0rgolFVX6r64poBHXM1t8qlFel1G1eCBvVgntE3fCJitmzSvH5JD/KVJN/snz6TfaX2CLdON7+s4WVQ==} + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} engines: {node: '>=v18'} - '@commitlint/is-ignored@20.4.1': - resolution: {integrity: sha512-In5EO4JR1lNsAv1oOBBO24V9ND1IqdAJDKZiEpdfjDl2HMasAcT7oA+5BKONv1pRoLG380DGPE2W2RIcUwdgLA==} + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} engines: {node: '>=v18'} - '@commitlint/lint@20.4.1': - resolution: {integrity: sha512-g94LrGl/c6UhuhDQqNqU232aslLEN2vzc7MPfQTHzwzM4GHNnEAwVWWnh0zX8S5YXecuLXDwbCsoGwmpAgPWKA==} + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} engines: {node: '>=v18'} - '@commitlint/load@20.4.0': - resolution: {integrity: sha512-Dauup/GfjwffBXRJUdlX/YRKfSVXsXZLnINXKz0VZkXdKDcaEILAi9oflHGbfydonJnJAbXEbF3nXPm9rm3G6A==} + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} engines: {node: '>=v18'} - '@commitlint/message@20.4.0': - resolution: {integrity: sha512-B5lGtvHgiLAIsK5nLINzVW0bN5hXv+EW35sKhYHE8F7V9Uz1fR4tx3wt7mobA5UNhZKUNgB/+ldVMQE6IHZRyA==} + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} engines: {node: '>=v18'} - '@commitlint/parse@20.4.1': - resolution: {integrity: sha512-XNtZjeRcFuAfUnhYrCY02+mpxwY4OmnvD3ETbVPs25xJFFz1nRo/25nHj+5eM+zTeRFvWFwD4GXWU2JEtoK1/w==} + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} engines: {node: '>=v18'} - '@commitlint/read@20.4.0': - resolution: {integrity: sha512-QfpFn6/I240ySEGv7YWqho4vxqtPpx40FS7kZZDjUJ+eHxu3azfhy7fFb5XzfTqVNp1hNoI3tEmiEPbDB44+cg==} + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} engines: {node: '>=v18'} - '@commitlint/resolve-extends@20.4.0': - resolution: {integrity: sha512-ay1KM8q0t+/OnlpqXJ+7gEFQNlUtSU5Gxr8GEwnVf2TPN3+ywc5DzL3JCxmpucqxfHBTFwfRMXxPRRnR5Ki20g==} + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} engines: {node: '>=v18'} - '@commitlint/rules@20.4.1': - resolution: {integrity: sha512-WtqypKEPbQEuJwJS4aKs0OoJRBKz1HXPBC9wRtzVNH68FLhPWzxXlF09hpUXM9zdYTpm4vAdoTGkWiBgQ/vL0g==} + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} engines: {node: '>=v18'} '@commitlint/to-lines@20.0.0': resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} engines: {node: '>=v18'} - '@commitlint/top-level@20.4.0': - resolution: {integrity: sha512-NDzq8Q6jmFaIIBC/GG6n1OQEaHdmaAAYdrZRlMgW6glYWGZ+IeuXmiymDvQNXPc82mVxq2KiE3RVpcs+1OeDeA==} + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} engines: {node: '>=v18'} - '@commitlint/types@20.4.0': - resolution: {integrity: sha512-aO5l99BQJ0X34ft8b0h7QFkQlqxC6e7ZPVmBKz13xM9O8obDaM1Cld4sQlJDXXU/VFuUzQ30mVtHjVz74TuStw==} + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} engines: {node: '>=v18'} '@conventional-changelog/git-client@1.0.1': @@ -999,6 +1001,18 @@ packages: conventional-commits-parser: optional: true + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -1174,8 +1188,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -1186,12 +1200,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -1241,11 +1255,11 @@ packages: '@fastify/sensible@6.0.4': resolution: {integrity: sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==} - '@fastify/static@9.0.0': - resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} - '@fastify/swagger-ui@5.2.5': - resolution: {integrity: sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==} + '@fastify/swagger-ui@5.2.6': + resolution: {integrity: sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==} '@fastify/swagger@9.7.0': resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} @@ -1256,33 +1270,33 @@ packages: '@firebase/app-check-interop-types@0.3.3': resolution: {integrity: sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==} - '@firebase/app-types@0.9.3': - resolution: {integrity: sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==} + '@firebase/app-types@0.9.4': + resolution: {integrity: sha512-crX9TA5SVYZwLPG7/R16IsH8FLlgkPXjJUVhsVpHVDSqJiq3D/NuFTM5ctxGTExXAOeIn//69tQw47CPerM8MQ==} '@firebase/auth-interop-types@0.2.4': resolution: {integrity: sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==} - '@firebase/component@0.7.0': - resolution: {integrity: sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==} + '@firebase/component@0.7.2': + resolution: {integrity: sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==} engines: {node: '>=20.0.0'} - '@firebase/database-compat@2.1.0': - resolution: {integrity: sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==} + '@firebase/database-compat@2.1.3': + resolution: {integrity: sha512-GMyfWjD8mehjg/QpNkY/tl9G/MoeugPeg91n9D0atggxbWuKF/2KhVPHZDH+XmoP0EKYqMWYTtKxBsaBaNKLYQ==} engines: {node: '>=20.0.0'} - '@firebase/database-types@1.0.16': - resolution: {integrity: sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==} + '@firebase/database-types@1.0.19': + resolution: {integrity: sha512-FqewjUZmV9LqFfuEnmgdcUpiOUz7qwLXxnm/H8BcMFEzQXtd1yyUDm8ex5VRad2nuTE+ahOuCjUAM/cyDncO+g==} - '@firebase/database@1.1.0': - resolution: {integrity: sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==} + '@firebase/database@1.1.2': + resolution: {integrity: sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==} engines: {node: '>=20.0.0'} '@firebase/logger@0.5.0': resolution: {integrity: sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==} engines: {node: '>=20.0.0'} - '@firebase/util@1.13.0': - resolution: {integrity: sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==} + '@firebase/util@1.15.0': + resolution: {integrity: sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==} engines: {node: '>=20.0.0'} '@google-cloud/firestore@7.11.6': @@ -1301,18 +1315,18 @@ packages: resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} engines: {node: '>=14'} - '@google-cloud/storage@7.17.2': - resolution: {integrity: sha512-6xN0KNO8L/LIA5zu3CJwHkJiB6n65eykBLOb0E+RooiHYgX8CSao6lvQiKT9TBk2gL5g33LL3fmhDodZnt56rw==} + '@google-cloud/storage@7.19.0': + resolution: {integrity: sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==} engines: {node: '>=14'} - '@graphql-tools/merge@9.1.7': - resolution: {integrity: sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==} + '@graphql-tools/merge@9.1.8': + resolution: {integrity: sha512-25V7WDrODo1cPrmuUCrqf5qlMA4a/Ow4aHaqJ1MnTUaluwsV3UiqzCHWux3HSLb0H63mkoZiuOrU5xJhxRcoCg==} engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@graphql-tools/utils@11.0.0': - resolution: {integrity: sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==} + '@graphql-tools/utils@11.1.0': + resolution: {integrity: sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==} engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 @@ -1322,8 +1336,8 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@grpc/grpc-js@1.14.0': - resolution: {integrity: sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} '@grpc/proto-loader@0.7.15': @@ -1497,10 +1511,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@isaacs/cliui@9.0.0': - resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} - engines: {node: '>=18'} - '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -1561,6 +1571,9 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1638,6 +1651,10 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1649,8 +1666,8 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@prefabs.tech/eslint-config@0.5.0': - resolution: {integrity: sha512-GFzcgTUqi25770aeQ7I89dk9y/ct5PEnUzburmISIewufejcTHcQtqU9EVGmuYECg8ZXRB6OyStULTeCKapAtA==} + '@prefabs.tech/eslint-config@0.7.0': + resolution: {integrity: sha512-kMLs+ksinlNKa5FfhTb2qNisnU/On+IGrRHbZ3aHq1INF8NsjVRB7Wm0Q9vdnBipco9YOady0qSx1goVnhhC3w==} peerDependencies: eslint: '>=9.0.0' prettier: '>=3.3.3' @@ -1661,8 +1678,8 @@ packages: engines: {node: '>10.17.0'} hasBin: true - '@prefabs.tech/tsconfig@0.5.0': - resolution: {integrity: sha512-lpu9UPVDpbpMKlClhImF8x0YIqSm6dTtBpVK8/BFkhfOrJR1hp5l1EBlvbkzEL2hxx1vx/cQ43dEDobAZvBBQA==} + '@prefabs.tech/tsconfig@0.7.0': + resolution: {integrity: sha512-MiEvKeoNVPSy79tYQOkFeaBoVViW27JYfSiEbCBT+Fvuk1XEkXyBpSxlpdKQDV5bsAg0C5VNSjRh4qH3eDjA+w==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -1670,8 +1687,8 @@ packages: '@protobufjs/base64@1.1.2': resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - '@protobufjs/codegen@2.0.4': - resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} '@protobufjs/eventemitter@1.1.0': resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} @@ -1682,8 +1699,8 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.0': - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -1691,8 +1708,8 @@ packages: '@protobufjs/pool@1.1.0': resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - '@protobufjs/utf8@1.1.0': - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} '@rollup/rollup-android-arm-eabi@4.52.5': resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} @@ -1824,6 +1841,14 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + '@sindresorhus/merge-streams@2.3.0': resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} @@ -1876,236 +1901,255 @@ packages: peerDependencies: zod: ^3 - '@smithy/abort-controller@4.2.8': - resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} engines: {node: '>=18.0.0'} - '@smithy/chunked-blob-reader-native@4.2.1': - resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} engines: {node: '>=18.0.0'} - '@smithy/chunked-blob-reader@5.2.0': - resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} + '@smithy/config-resolver@4.4.17': + resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.6': - resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + '@smithy/core@3.23.17': + resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.0': - resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} + '@smithy/credential-provider-imds@4.2.14': + resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + '@smithy/eventstream-codec@4.2.14': + resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.8': - resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} + '@smithy/eventstream-serde-browser@4.2.14': + resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.8': - resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} + '@smithy/eventstream-serde-config-resolver@4.3.14': + resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.8': - resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} + '@smithy/eventstream-serde-node@4.2.14': + resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.8': - resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} + '@smithy/eventstream-serde-universal@4.2.14': + resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.8': - resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} + '@smithy/fetch-http-handler@5.3.17': + resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.9': - resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + '@smithy/hash-blob-browser@4.2.15': + resolution: {integrity: sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==} engines: {node: '>=18.0.0'} - '@smithy/hash-blob-browser@4.2.9': - resolution: {integrity: sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==} + '@smithy/hash-node@4.2.14': + resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.8': - resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + '@smithy/hash-stream-node@4.2.14': + resolution: {integrity: sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==} engines: {node: '>=18.0.0'} - '@smithy/hash-stream-node@4.2.8': - resolution: {integrity: sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==} - engines: {node: '>=18.0.0'} - - '@smithy/invalid-dependency@4.2.8': - resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + '@smithy/invalid-dependency@4.2.14': + resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} - engines: {node: '>=18.0.0'} - - '@smithy/md5-js@4.2.8': - resolution: {integrity: sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==} + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.8': - resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + '@smithy/md5-js@4.2.14': + resolution: {integrity: sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.14': - resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} + '@smithy/middleware-content-length@4.2.14': + resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.31': - resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} + '@smithy/middleware-endpoint@4.4.32': + resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.9': - resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + '@smithy/middleware-retry@4.5.7': + resolution: {integrity: sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.8': - resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + '@smithy/middleware-serde@4.2.20': + resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.8': - resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + '@smithy/middleware-stack@4.2.14': + resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.10': - resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} + '@smithy/node-config-provider@4.3.14': + resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + '@smithy/node-http-handler@4.6.1': + resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.8': - resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + '@smithy/property-provider@4.2.14': + resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.8': - resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + '@smithy/protocol-http@5.3.14': + resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.8': - resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + '@smithy/querystring-builder@4.2.14': + resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.8': - resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + '@smithy/querystring-parser@4.2.14': + resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.3': - resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + '@smithy/service-error-classification@4.3.1': + resolution: {integrity: sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.8': - resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + '@smithy/shared-ini-file-loader@4.4.9': + resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.11.3': - resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} + '@smithy/signature-v4@5.3.14': + resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} engines: {node: '>=18.0.0'} - '@smithy/types@4.12.0': - resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + '@smithy/smithy-client@4.12.13': + resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} engines: {node: '>=18.0.0'} - '@smithy/types@4.8.0': - resolution: {integrity: sha512-QpELEHLO8SsQVtqP+MkEgCYTFW0pleGozfs3cZ183ZBj9z3VC1CX1/wtFMK64p+5bhtZo41SeLK1rBRtd25nHQ==} + '@smithy/types@4.14.1': + resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.8': - resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + '@smithy/url-parser@4.2.14': + resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.3.0': - resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.0': - resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.1': - resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} engines: {node: '>=18.0.0'} '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.0': - resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.30': - resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} + '@smithy/util-defaults-mode-browser@4.3.49': + resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.33': - resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} + '@smithy/util-defaults-mode-node@4.2.54': + resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.2.8': - resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + '@smithy/util-endpoints@3.4.2': + resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.2.8': - resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + '@smithy/util-middleware@4.2.14': + resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.8': - resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + '@smithy/util-retry@4.3.8': + resolution: {integrity: sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.12': - resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} + '@smithy/util-stream@4.5.25': + resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.0': - resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} engines: {node: '>=18.0.0'} '@smithy/util-utf8@2.3.0': resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} engines: {node: '>=18.0.0'} - '@smithy/util-waiter@4.2.8': - resolution: {integrity: sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==} + '@smithy/util-waiter@4.3.0': + resolution: {integrity: sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==} engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.0': - resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} + '@turbo/darwin-64@2.9.9': + resolution: {integrity: sha512-hTEiNu2ABZZOO1qbjnKASI8eF3BdOOzU6iKv5w5uGOK65DDMc10cS40N1kqM99YT0uSAGUwNu6GdFctRPeEeVA==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.9': + resolution: {integrity: sha512-MinO40EEcP5mJiTVpfjtEulsEBhVeryfq21QhYtJZ8hQJLHGgy459rcmDVAY8/JERe4dkVU4KW+zoLF22o01EA==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.9': + resolution: {integrity: sha512-7JNLw88Isk+gMlbsC8pulLDkrqe2B827ZsKFEHilb17AC6Xn/62pzH7afjY7fEU6Ayp4XP/vGhlRWOzqBvBvIQ==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.9': + resolution: {integrity: sha512-0pnXDwPw1rHii98JZPRg7SvsjIzy7jrhkwGU9Jy5fVYoMdYd3P2vbtLfII+OJ0Mm4Ar5yykdHDTz3RWiRI1o9g==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.9': + resolution: {integrity: sha512-vjDQycz4gQVvIq4n2rPtiiIESwJlAc406qtkiZlqyL+fHZEd9SxYNlBIFYtc5cuMuwrk+sIKrhN7XvwjmvS9YQ==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.9': + resolution: {integrity: sha512-V6NiH43oCctepbOdQFp7UjqLyK8p6Tt824QA+G4TE+B1BBHu80A0W8OCL+H7uBJ3XZjAj/hvPDw3k3l65DoDGw==} + cpu: [arm64] + os: [win32] + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/busboy@1.5.4': resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} @@ -2115,27 +2159,15 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/express-serve-static-core@4.19.7': - resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==} - - '@types/express@4.17.24': - resolution: {integrity: sha512-Mbrt4SRlXSTWryOnHAh2d4UQ/E7n9lZyGSi6KgX+4hkuL9soYbLOVXVhnk/ODp12YsGc95f4pOvqywJ6kngUwg==} - '@types/html-to-text@9.0.4': resolution: {integrity: sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==} - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/humps@2.0.6': resolution: {integrity: sha512-Fagm1/a/1J9gDKzGdtlPmmTN5eSw/aaTzHtj740oSfo+MODsSY2WglxMmhTdOglC8nxqUhGGQ+5HfVtBvxo3Kg==} @@ -2151,9 +2183,6 @@ packages: '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/mjml-core@4.15.2': resolution: {integrity: sha512-Q7SxFXgoX979HP57DEVsRI50TV8x1V4lfCA4Up9AvfINDM5oD/X9ARgfoyX1qS987JCnDLv85JjkqAjt3hZSiQ==} @@ -2163,26 +2192,17 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@22.18.12': - resolution: {integrity: sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==} - - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + '@types/node@24.10.15': + resolution: {integrity: sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==} '@types/nodemailer-html-to-text@3.1.3': resolution: {integrity: sha512-Oo3UfBz/jscdgltyp7HABiSHd7aiUuQKxqklPea1san3hNN4w79PEf4S27NQ04JdLO0sU5ZDGK9S+tMHj15vcg==} - '@types/nodemailer@6.4.22': - resolution: {integrity: sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==} - - '@types/pg@8.16.0': - resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} - - '@types/qs@6.14.0': - resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + '@types/nodemailer@6.4.23': + resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==} - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} '@types/request@2.48.13': resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} @@ -2190,15 +2210,6 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} - - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - '@types/stack-trace@0.0.33': resolution: {integrity: sha512-O7in6531Bbvlb2KEsJ0dq0CHZvc3iWSR5ZYMtvGgnHA56VgriAN/AU2LorfmcvAl2xc9N5fbCTRyMRRl8nd74g==} @@ -2211,63 +2222,63 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} - '@typescript-eslint/eslint-plugin@8.54.0': - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.54.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser': 8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.54.0': - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.54.0': - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.54.0': - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.54.0': - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.54.0': - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.54.0': - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.54.0': - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.54.0': - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.54.0': - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -2407,14 +2418,6 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vue/tsconfig@0.1.3': - resolution: {integrity: sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - '@whatwg-node/promise-helpers@1.3.2': resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} engines: {node: '>=16.0.0'} @@ -2458,11 +2461,11 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} @@ -2568,16 +2571,13 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - avvio@9.1.0: - resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} axe-core@4.11.1: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} - axios@1.12.2: - resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} - axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} @@ -2588,9 +2588,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.2: - resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} - engines: {node: 20 || >=22} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2616,8 +2616,8 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - bowser@2.12.1: - resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2625,9 +2625,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -2804,8 +2804,12 @@ packages: resolution: {integrity: sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w==} engines: {node: '>=18'} - conventional-changelog-conventionalcommits@9.1.0: - resolution: {integrity: sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA==} + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} engines: {node: '>=18'} conventional-changelog-core@9.0.0: @@ -2830,6 +2834,11 @@ packages: engines: {node: '>=18'} hasBin: true + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2837,23 +2846,23 @@ packages: resolution: {integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==} engines: {node: '>= 0.6'} - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} core-js-compat@3.48.0: resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} - cosmiconfig-typescript-loader@6.2.0: - resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} engines: {node: '>=v18'} peerDependencies: '@types/node': '*' cosmiconfig: '>=9' typescript: '>=5' - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} peerDependencies: typescript: '>=4.9.5' @@ -2895,13 +2904,13 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - dargs@8.1.0: - resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} - engines: {node: '>=12'} - data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3125,6 +3134,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esbuild@0.25.11: resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} engines: {node: '>=18'} @@ -3241,6 +3253,12 @@ packages: peerDependencies: eslint: '>=8.23.0' + eslint-plugin-perfectionist@5.9.0: + resolution: {integrity: sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-prettier@5.5.5: resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3284,7 +3302,7 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 - '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + '@typescript-eslint/parser': 8.58.0 eslint: ^8.57.0 || ^9.0.0 vue-eslint-parser: ^10.0.0 peerDependenciesMeta: @@ -3305,8 +3323,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -3383,8 +3405,8 @@ packages: fast-json-stringify@5.16.1: resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} - fast-json-stringify@6.1.1: - resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} @@ -3406,19 +3428,21 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-parser@4.5.3: - resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} - hasBin: true + fast-uri@3.1.1: + resolution: {integrity: sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==} + + fast-xml-builder@1.1.7: + resolution: {integrity: sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==} - fast-xml-parser@5.3.4: - resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + fast-xml-parser@5.7.1: + resolution: {integrity: sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==} hasBin: true fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - fastify@5.7.4: - resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} fastparallel@2.4.1: resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} @@ -3426,6 +3450,9 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + faye-websocket@0.11.4: resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} engines: {node: '>=0.8.0'} @@ -3442,6 +3469,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -3457,8 +3488,8 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-my-way@9.3.0: - resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} find-up-simple@1.0.1: @@ -3469,8 +3500,8 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - firebase-admin@13.6.1: - resolution: {integrity: sha512-Zgc6yPtmPxAZo+FoK6LMG6zpSEsoSK8ifIR+IqF4oWuC3uWZU40OjxgfLTSFcsRlj/k/wD66zNv2UiTRreCNSw==} + firebase-admin@13.8.0: + resolution: {integrity: sha512-iawoQkmZbsA+2DY5UEuB8f6jSlskzzySoye0D2F6e3zlDZX9DUcXf0HhZqLUn/P6WhLGvTf6ZtCmshZvhAgTYg==} engines: {node: '>=18'} flat-cache@4.0.1: @@ -3501,14 +3532,14 @@ packages: resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} engines: {node: '>= 0.12'} - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3535,10 +3566,18 @@ packages: resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} engines: {node: '>=14'} + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + gcp-metadata@6.1.1: resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} engines: {node: '>=14'} + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} @@ -3580,19 +3619,21 @@ packages: get-tsconfig@4.13.1: resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} - git-raw-commits@4.0.0: - resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} - engines: {node: '>=16'} - hasBin: true - git-raw-commits@5.0.0: resolution: {integrity: sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg==} engines: {node: '>=18'} + deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} hasBin: true git-semver-tags@8.0.0: resolution: {integrity: sha512-N7YRIklvPH3wYWAR2vysaqGLPRcpwQ0GKdlqTiVN5w1UmCdaeY3K8s6DMKRCh54DDdzyt/OAB6C8jgVtb7Y2Fg==} engines: {node: '>=18'} + deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -3605,16 +3646,15 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.4: - resolution: {integrity: sha512-KACie1EOs9BIOMtenFaxwmYODWA3/fTfGSUnLhMJpXRntu1g+uL/Xvub5f8SCTppvo9q62Qy4LeOoUiaL54G5A==} + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} engines: {node: 20 || >=22} - global-directory@4.0.1: - resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} - engines: {node: '>=18'} + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -3640,6 +3680,10 @@ packages: resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} engines: {node: '>=18'} + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} @@ -3652,6 +3696,10 @@ packages: resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} engines: {node: '>=14'} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3676,16 +3724,16 @@ packages: peerDependencies: graphql: 0.13.1 - 16 - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -3716,6 +3764,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -3844,9 +3896,9 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - ini@4.1.1: - resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} inquirer@12.11.1: resolution: {integrity: sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw==} @@ -3865,8 +3917,8 @@ packages: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} - ipaddr.js@2.2.0: - resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} is-array-buffer@3.0.5: @@ -4020,8 +4072,8 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} - is-wsl@3.1.1: - resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} isarray@2.0.5: @@ -4060,10 +4112,6 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jackspeak@4.2.3: - resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} - engines: {node: 20 || >=22} - jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -4144,8 +4192,8 @@ packages: jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} jsx-ast-utils@3.3.5: @@ -4157,21 +4205,15 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - jwks-rsa@3.2.0: - resolution: {integrity: sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==} + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} engines: {node: '>=14'} - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - - jws@4.0.0: - resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4236,9 +4278,6 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - lodash.kebabcase@4.1.1: - resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} - lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -4251,15 +4290,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.snakecase@4.1.1: - resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} - - lodash.startcase@4.4.0: - resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} - - lodash.upperfirst@4.3.1: - resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} - lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4318,10 +4348,6 @@ packages: mensch@0.3.4: resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==} - meow@12.1.1: - resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} - engines: {node: '>=16.10'} - meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -4329,8 +4355,8 @@ packages: mercurius-auth@6.0.0: resolution: {integrity: sha512-pwxoF2xrYaItu9GFpNMFzNULmC7JCC3XCRzYtUOG8TgmrCtQ5N0SSKnA92FGkt/Q/5/3FfFJIiF4GSW6iRbolQ==} - mercurius@16.7.0: - resolution: {integrity: sha512-xCu0qfOIvCm52jKKXFzl9VfPp+9DQrP/5cS0qt8OAA1xZomjTniK4ZlodOP1Q6dsgdfp1RtLx/KQcP7283febw==} + mercurius@16.9.0: + resolution: {integrity: sha512-3td+e+SZyA7Gp/Tb51ncYdOGD5OsXh6zK4FjUJx+2pSHbJJEWE11EjKAAqhf3Xrp2JjMlg6dBrmRWsdM9E5G1Q==} engines: {node: ^20.9.0 || >=22.0.0} peerDependencies: graphql: ^16.0.0 @@ -4369,12 +4395,12 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - minimatch@10.2.1: - resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} - engines: {node: 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} @@ -4535,6 +4561,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + nearley@2.20.1: resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} hasBin: true @@ -4552,9 +4582,10 @@ packages: resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} engines: {node: '>=6.0.0'} - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} @@ -4565,8 +4596,12 @@ packages: encoding: optional: true - node-forge@1.3.1: - resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} node-gyp-build-optional-packages@5.2.2: @@ -4732,6 +4767,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4768,8 +4807,8 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.11.0: - resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} pg-cursor@2.15.3: resolution: {integrity: sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==} @@ -4780,8 +4819,8 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-mem@3.0.12: - resolution: {integrity: sha512-XZG5DsqKPPX0UdYW+ihshNrHzF+x6uEV/50x4jZxQo6iFMl02mYdP1DFnkVwfUKLFgi/bKz5vlVYmDFDGFDEgw==} + pg-mem@3.0.14: + resolution: {integrity: sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==} peerDependencies: '@mikro-orm/core': '>=4.5.3' '@mikro-orm/postgresql': '>=4.5.3' @@ -4819,16 +4858,13 @@ packages: resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} engines: {node: '>=4'} - pg-pool@3.11.0: - resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} peerDependencies: pg: '>=8.0' - pg-protocol@1.10.3: - resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} - - pg-protocol@1.11.0: - resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} pg-query-stream@4.10.3: resolution: {integrity: sha512-h2utrzpOIzeT9JfaqfvBbVuvCfBjH86jNfVrGGTbyepKAIOyTfDew0lAt8bbJjs9n/I5bGDl7S2sx6h5hPyJxw==} @@ -4843,8 +4879,8 @@ packages: resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==} engines: {node: '>=10'} - pg@8.18.0: - resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -4878,8 +4914,8 @@ packages: pino-std-serializers@6.2.2: resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} - pino-std-serializers@7.0.0: - resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} pino@10.3.1: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} @@ -4913,8 +4949,8 @@ packages: resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} engines: {node: '>=12'} - postgres-bytea@1.0.0: - resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} engines: {node: '>=0.10.0'} postgres-bytea@3.0.0: @@ -4952,8 +4988,8 @@ packages: resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} hasBin: true @@ -4991,8 +5027,8 @@ packages: resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} engines: {node: '>=14.0.0'} - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + protobufjs@7.5.5: + resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} proxy-from-env@1.1.0: @@ -5114,9 +5150,8 @@ packages: engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} - engines: {node: '>= 0.4'} + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true ret@0.1.15: @@ -5180,8 +5215,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - safe-regex2@5.0.0: - resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} @@ -5212,6 +5248,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -5224,8 +5265,8 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -5254,11 +5295,11 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} - shipjs-lib@0.28.2: - resolution: {integrity: sha512-7jBXk7QgqNw1TUGynoHQVWETG8Uo6Jq17SOUsQa2Z7rTFPzkPoVHUojyhyjvR3NKLbtEugnw4d0e8SJivUOXJg==} + shipjs-lib@0.28.3: + resolution: {integrity: sha512-AMgTdZ48PJnQZ09U23m0i+t2fvP1eL61dXcuj+q9k6PB6sUhZTHLvLgyC5IiO8rC0lYNEYHM4WjPZpNW3KuFwg==} - shipjs@0.28.2: - resolution: {integrity: sha512-OiPMfOfPFF43kuciBCsH6GoF5TVRc0OFN8I0EUz/zUt3eiMz8pPIHa7l5dG9ueBmSaSNP4gTE/DYg56LP6JYyw==} + shipjs@0.28.3: + resolution: {integrity: sha512-+u4dhQmP2zrK98f7dyTPLpzqLS8BwypJ86iCXQPYjEOzRxXzo2enQAyzUQ1wnDTZlm/dADg02716er4ZIk4KPA==} engines: {node: '>=20'} hasBin: true @@ -5310,8 +5351,8 @@ packages: sonic-boom@3.8.1: resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -5352,8 +5393,8 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} - stacktracey@2.1.8: - resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} + stacktracey@2.2.0: + resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==} standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -5446,11 +5487,8 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - strnum@1.1.2: - resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} - - strnum@2.1.1: - resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} stubs@3.0.0: resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} @@ -5504,8 +5542,8 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} - tiny-lru@11.4.5: - resolution: {integrity: sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==} + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} engines: {node: '>=12'} tinybench@2.9.0: @@ -5514,8 +5552,9 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.0.1: - resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} @@ -5548,8 +5587,8 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -5565,38 +5604,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - turbo-darwin-64@2.8.7: - resolution: {integrity: sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A==} - cpu: [x64] - os: [darwin] - - turbo-darwin-arm64@2.8.7: - resolution: {integrity: sha512-p8Xbmb9kZEY/NoshQUcFmQdO80s2PCGoLYj5DbpxjZr3diknipXxzOK7pcmT7l2gNHaMCpFVWLkiFY9nO3EU5w==} - cpu: [arm64] - os: [darwin] - - turbo-linux-64@2.8.7: - resolution: {integrity: sha512-nwfEPAH3m5y/nJeYly3j1YJNYU2EG5+2ysZUxvBNM+VBV2LjQaLxB9CsEIpIOKuWKCjnFHKIADTSDPZ3D12J5Q==} - cpu: [x64] - os: [linux] - - turbo-linux-arm64@2.8.7: - resolution: {integrity: sha512-mgA/M6xiJzyxtXV70TtWGDPh+I6acOKmeQGtOzbFQZYEf794pu5jax26bCk5skAp1gqZu3vacPr6jhYHoHU9IQ==} - cpu: [arm64] - os: [linux] - - turbo-windows-64@2.8.7: - resolution: {integrity: sha512-sHTYMaXuCcyHnGUQgfUUt7S8407TWoP14zc/4N2tsM0wZNK6V9h4H2t5jQPtqKEb6Fg8313kygdDgEwuM4vsHg==} - cpu: [x64] - os: [win32] - - turbo-windows-arm64@2.8.7: - resolution: {integrity: sha512-WyGiOI2Zp3AhuzVagzQN+T+iq0fWx0oGxDfAWT3ZiLEd4U0cDUkwUZDKVGb3rKqPjDL6lWnuxKKu73ge5xtovQ==} - cpu: [arm64] - os: [win32] - - turbo@2.8.7: - resolution: {integrity: sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA==} + turbo@2.9.9: + resolution: {integrity: sha512-3xfzXE/yTjhh0S5dIWlE+3E+J9A09REpLI1ZqVh2+HrNZoVzZn0pkvjiRgVK/Ev3PF9XnaTwCntTx+CADWXcyA==} hasBin: true twilio@4.23.0: @@ -5635,12 +5644,12 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.54.0: - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -5656,9 +5665,6 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -5704,8 +5710,13 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: @@ -5719,8 +5730,8 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - validator@13.15.26: - resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} engines: {node: '>= 0.10'} vary@1.1.2: @@ -5732,8 +5743,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -5813,6 +5824,10 @@ packages: resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==} engines: {node: '>=10.0.0'} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5875,8 +5890,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5952,21 +5967,21 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.8 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.8 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-locate-window': 3.893.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -5975,15 +5990,15 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-locate-window': 3.893.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.973.8 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -5992,66 +6007,66 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.914.0 + '@aws-sdk/types': 3.973.8 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.989.0': + '@aws-sdk/client-s3@3.1042.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-node': 3.972.9 - '@aws-sdk/middleware-bucket-endpoint': 3.972.3 - '@aws-sdk/middleware-expect-continue': 3.972.3 - '@aws-sdk/middleware-flexible-checksums': 3.972.8 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-location-constraint': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-sdk-s3': 3.972.10 - '@aws-sdk/middleware-ssec': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/signature-v4-multi-region': 3.989.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.989.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/eventstream-serde-config-resolver': 4.3.8 - '@smithy/eventstream-serde-node': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-blob-browser': 4.2.9 - '@smithy/hash-node': 4.2.8 - '@smithy/hash-stream-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/md5-js': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/util-waiter': 4.2.8 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-node': 3.972.39 + '@aws-sdk/middleware-bucket-endpoint': 3.972.10 + '@aws-sdk/middleware-expect-continue': 3.972.10 + '@aws-sdk/middleware-flexible-checksums': 3.974.16 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-location-constraint': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/middleware-ssec': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/eventstream-serde-config-resolver': 4.3.14 + '@smithy/eventstream-serde-node': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-blob-browser': 4.2.15 + '@smithy/hash-node': 4.2.14 + '@smithy/hash-stream-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/md5-js': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.3.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -6060,486 +6075,442 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-node': 3.972.9 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-sdk-sqs': 3.972.7 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-node': 3.972.39 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-sdk-sqs': 3.972.22 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/types': 3.973.8 '@aws-sdk/util-endpoints': 3.991.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/md5-js': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/md5-js': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso@3.990.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.990.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.973.10': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/xml-builder': 3.972.4 - '@smithy/core': 3.23.0 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core@3.974.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.22 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/crc64-nvme@3.972.0': + '@aws-sdk/crc64-nvme@3.972.7': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.8': + '@aws-sdk/credential-provider-env@3.972.34': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.10': - dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 + '@aws-sdk/credential-provider-http@3.972.36': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.8': - dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/credential-provider-env': 3.972.8 - '@aws-sdk/credential-provider-http': 3.972.10 - '@aws-sdk/credential-provider-login': 3.972.8 - '@aws-sdk/credential-provider-process': 3.972.8 - '@aws-sdk/credential-provider-sso': 3.972.8 - '@aws-sdk/credential-provider-web-identity': 3.972.8 - '@aws-sdk/nested-clients': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/credential-provider-ini@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-login': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.8': + '@aws-sdk/credential-provider-login@3.972.38': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/nested-clients': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.9': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.8 - '@aws-sdk/credential-provider-http': 3.972.10 - '@aws-sdk/credential-provider-ini': 3.972.8 - '@aws-sdk/credential-provider-process': 3.972.8 - '@aws-sdk/credential-provider-sso': 3.972.8 - '@aws-sdk/credential-provider-web-identity': 3.972.8 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/credential-provider-node@3.972.39': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-ini': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.8': + '@aws-sdk/credential-provider-process@3.972.34': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.8': + '@aws-sdk/credential-provider-sso@3.972.38': dependencies: - '@aws-sdk/client-sso': 3.990.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/token-providers': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.8': + '@aws-sdk/credential-provider-web-identity@3.972.38': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/nested-clients': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/lib-storage@3.989.0(@aws-sdk/client-s3@3.989.0)': + '@aws-sdk/lib-storage@3.1042.0(@aws-sdk/client-s3@3.1042.0)': dependencies: - '@aws-sdk/client-s3': 3.989.0 - '@smithy/abort-controller': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/smithy-client': 4.11.3 + '@aws-sdk/client-s3': 3.1042.0 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 buffer: 5.6.0 events: 3.3.0 stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.3': + '@aws-sdk/middleware-bucket-endpoint@3.972.10': dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-expect-continue@3.972.3': + '@aws-sdk/middleware-expect-continue@3.972.10': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.972.8': + '@aws-sdk/middleware-flexible-checksums@3.974.16': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/crc64-nvme': 3.972.0 - '@aws-sdk/types': 3.973.1 - '@smithy/is-array-buffer': 4.2.0 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/crc64-nvme': 3.972.7 + '@aws-sdk/types': 3.973.8 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-host-header@3.972.3': + '@aws-sdk/middleware-host-header@3.972.10': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-location-constraint@3.972.3': + '@aws-sdk/middleware-location-constraint@3.972.10': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-logger@3.972.3': + '@aws-sdk/middleware-logger@3.972.10': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.3': + '@aws-sdk/middleware-recursion-detection@3.972.11': dependencies: - '@aws-sdk/types': 3.973.1 - '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.8 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.10': - dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/core': 3.23.0 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/middleware-sdk-s3@3.972.37': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-sqs@3.972.7': + '@aws-sdk/middleware-sdk-sqs@3.972.22': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/types': 3.973.8 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@aws-sdk/middleware-ssec@3.972.3': + '@aws-sdk/middleware-ssec@3.972.10': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/middleware-user-agent@3.972.10': + '@aws-sdk/middleware-user-agent@3.972.38': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.990.0 - '@smithy/core': 3.23.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-retry': 4.3.8 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.990.0': + '@aws-sdk/nested-clients@3.997.6': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.990.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.8 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.0 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-retry': 4.4.31 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.30 - '@smithy/util-defaults-mode-node': 4.2.33 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/region-config-resolver@3.972.3': + '@aws-sdk/region-config-resolver@3.972.13': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/config-resolver': 4.4.6 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.8 + '@smithy/config-resolver': 4.4.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.989.0': + '@aws-sdk/s3-request-presigner@3.1042.0': dependencies: - '@aws-sdk/signature-v4-multi-region': 3.989.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-format-url': 3.972.3 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-format-url': 3.972.10 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.989.0': + '@aws-sdk/signature-v4-multi-region@3.996.25': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.10 - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.990.0': + '@aws-sdk/token-providers@3.1041.0': dependencies: - '@aws-sdk/core': 3.973.10 - '@aws-sdk/nested-clients': 3.990.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.914.0': + '@aws-sdk/types@3.973.8': dependencies: - '@smithy/types': 4.8.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/types@3.973.1': + '@aws-sdk/util-arn-parser@3.972.3': dependencies: - '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/util-arn-parser@3.972.2': + '@aws-sdk/util-endpoints@3.991.0': dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-endpoints': 3.4.2 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.989.0': + '@aws-sdk/util-endpoints@3.996.8': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-endpoints': 3.4.2 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.990.0': + '@aws-sdk/util-format-url@3.972.10': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 + '@aws-sdk/types': 3.973.8 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.991.0': + '@aws-sdk/util-locate-window@3.965.5': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 tslib: 2.8.1 - '@aws-sdk/util-format-url@3.972.3': + '@aws-sdk/util-user-agent-browser@3.972.10': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.893.0': + '@aws-sdk/util-user-agent-node@3.973.24': dependencies: + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.3': + '@aws-sdk/xml-builder@3.972.22': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - bowser: 2.12.1 + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.1 + fast-xml-parser: 5.7.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.972.8': - dependencies: - '@aws-sdk/middleware-user-agent': 3.972.10 - '@aws-sdk/types': 3.973.1 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} - '@aws-sdk/xml-builder@3.972.4': + '@babel/code-frame@7.27.1': dependencies: - '@smithy/types': 4.12.0 - fast-xml-parser: 5.3.4 - tslib: 2.8.1 - - '@aws/lambda-invoke-store@0.2.3': {} + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 - '@babel/code-frame@7.27.1': + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 @@ -6641,113 +6612,114 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@commitlint/cli@20.4.1(@types/node@24.10.13)(typescript@5.9.3)': + '@commitlint/cli@20.5.3(@types/node@24.10.15)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': dependencies: - '@commitlint/format': 20.4.0 - '@commitlint/lint': 20.4.1 - '@commitlint/load': 20.4.0(@types/node@24.10.13)(typescript@5.9.3) - '@commitlint/read': 20.4.0 - '@commitlint/types': 20.4.0 - tinyexec: 1.0.1 + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@24.10.15)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.1.2 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' + - conventional-commits-filter + - conventional-commits-parser - typescript - '@commitlint/config-conventional@20.4.1': + '@commitlint/config-conventional@20.5.3': dependencies: - '@commitlint/types': 20.4.0 - conventional-changelog-conventionalcommits: 9.1.0 + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 - '@commitlint/config-validator@20.4.0': + '@commitlint/config-validator@20.5.0': dependencies: - '@commitlint/types': 20.4.0 - ajv: 8.17.1 + '@commitlint/types': 20.5.0 + ajv: 8.20.0 - '@commitlint/ensure@20.4.1': + '@commitlint/ensure@20.5.3': dependencies: - '@commitlint/types': 20.4.0 - lodash.camelcase: 4.3.0 - lodash.kebabcase: 4.1.1 - lodash.snakecase: 4.1.1 - lodash.startcase: 4.4.0 - lodash.upperfirst: 4.3.1 + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 '@commitlint/execute-rule@20.0.0': {} - '@commitlint/format@20.4.0': + '@commitlint/format@20.5.0': dependencies: - '@commitlint/types': 20.4.0 + '@commitlint/types': 20.5.0 picocolors: 1.1.1 - '@commitlint/is-ignored@20.4.1': + '@commitlint/is-ignored@20.5.0': dependencies: - '@commitlint/types': 20.4.0 + '@commitlint/types': 20.5.0 semver: 7.7.4 - '@commitlint/lint@20.4.1': + '@commitlint/lint@20.5.3': dependencies: - '@commitlint/is-ignored': 20.4.1 - '@commitlint/parse': 20.4.1 - '@commitlint/rules': 20.4.1 - '@commitlint/types': 20.4.0 + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 - '@commitlint/load@20.4.0(@types/node@24.10.13)(typescript@5.9.3)': + '@commitlint/load@20.5.3(@types/node@24.10.15)(typescript@5.9.3)': dependencies: - '@commitlint/config-validator': 20.4.0 + '@commitlint/config-validator': 20.5.0 '@commitlint/execute-rule': 20.0.0 - '@commitlint/resolve-extends': 20.4.0 - '@commitlint/types': 20.4.0 - cosmiconfig: 9.0.0(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.2.0(@types/node@24.10.13)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.10.15)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.46.1 is-plain-obj: 4.1.0 - lodash.mergewith: 4.6.2 picocolors: 1.1.1 transitivePeerDependencies: - '@types/node' - typescript - '@commitlint/message@20.4.0': {} + '@commitlint/message@20.4.3': {} - '@commitlint/parse@20.4.1': + '@commitlint/parse@20.5.0': dependencies: - '@commitlint/types': 20.4.0 - conventional-changelog-angular: 8.1.0 - conventional-commits-parser: 6.2.1 + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 - '@commitlint/read@20.4.0': + '@commitlint/read@20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': dependencies: - '@commitlint/top-level': 20.4.0 - '@commitlint/types': 20.4.0 - git-raw-commits: 4.0.0 + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) minimist: 1.2.8 - tinyexec: 1.0.1 + tinyexec: 1.1.2 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser - '@commitlint/resolve-extends@20.4.0': + '@commitlint/resolve-extends@20.5.3': dependencies: - '@commitlint/config-validator': 20.4.0 - '@commitlint/types': 20.4.0 - global-directory: 4.0.1 + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.46.1 + global-directory: 5.0.0 import-meta-resolve: 4.2.0 - lodash.mergewith: 4.6.2 resolve-from: 5.0.0 - '@commitlint/rules@20.4.1': + '@commitlint/rules@20.5.3': dependencies: - '@commitlint/ensure': 20.4.1 - '@commitlint/message': 20.4.0 + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 '@commitlint/to-lines': 20.0.0 - '@commitlint/types': 20.4.0 + '@commitlint/types': 20.5.0 '@commitlint/to-lines@20.0.0': {} - '@commitlint/top-level@20.4.0': + '@commitlint/top-level@20.4.3': dependencies: escalade: 3.2.0 - '@commitlint/types@20.4.0': + '@commitlint/types@20.5.0': dependencies: - conventional-commits-parser: 6.2.1 + conventional-commits-parser: 6.4.0 picocolors: 1.1.1 '@conventional-changelog/git-client@1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1)': @@ -6758,6 +6730,15 @@ snapshots: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.2.1 + '@conventional-changelog/git-client@2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.7.4 + optionalDependencies: + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -6852,18 +6833,18 @@ snapshots: '@esbuild/win32-x64@0.25.11': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -6875,21 +6856,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.3': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.2': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -6902,9 +6883,9 @@ snapshots: '@fastify/ajv-compiler@4.0.5': dependencies: - ajv: 8.17.1 + ajv: 8.20.0 ajv-formats: 3.0.1 - fast-uri: 3.1.0 + fast-uri: 3.1.1 '@fastify/busboy@3.2.0': {} @@ -6914,7 +6895,7 @@ snapshots: '@fastify/fast-json-stringify-compiler@5.0.3': dependencies: - fast-json-stringify: 6.1.1 + fast-json-stringify: 6.3.0 '@fastify/forwarded@3.0.1': {} @@ -6937,7 +6918,7 @@ snapshots: '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 - ipaddr.js: 2.2.0 + ipaddr.js: 2.4.0 '@fastify/send@4.1.0': dependencies: @@ -6957,18 +6938,18 @@ snapshots: type-is: 2.0.1 vary: 1.1.2 - '@fastify/static@9.0.0': + '@fastify/static@9.1.3': dependencies: '@fastify/accept-negotiator': 2.0.1 '@fastify/send': 4.1.0 content-disposition: 1.0.1 fastify-plugin: 5.1.0 fastq: 1.19.1 - glob: 13.0.4 + glob: 13.0.0 - '@fastify/swagger-ui@5.2.5': + '@fastify/swagger-ui@5.2.6': dependencies: - '@fastify/static': 9.0.0 + '@fastify/static': 9.1.3 fastify-plugin: 5.1.0 openapi-types: 12.1.3 rfdc: 1.4.1 @@ -6988,43 +6969,45 @@ snapshots: dependencies: duplexify: 4.1.3 fastify-plugin: 5.1.0 - ws: 8.18.3 + ws: 8.20.0 transitivePeerDependencies: - bufferutil - utf-8-validate '@firebase/app-check-interop-types@0.3.3': {} - '@firebase/app-types@0.9.3': {} + '@firebase/app-types@0.9.4': + dependencies: + '@firebase/logger': 0.5.0 '@firebase/auth-interop-types@0.2.4': {} - '@firebase/component@0.7.0': + '@firebase/component@0.7.2': dependencies: - '@firebase/util': 1.13.0 + '@firebase/util': 1.15.0 tslib: 2.8.1 - '@firebase/database-compat@2.1.0': + '@firebase/database-compat@2.1.3': dependencies: - '@firebase/component': 0.7.0 - '@firebase/database': 1.1.0 - '@firebase/database-types': 1.0.16 + '@firebase/component': 0.7.2 + '@firebase/database': 1.1.2 + '@firebase/database-types': 1.0.19 '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 + '@firebase/util': 1.15.0 tslib: 2.8.1 - '@firebase/database-types@1.0.16': + '@firebase/database-types@1.0.19': dependencies: - '@firebase/app-types': 0.9.3 - '@firebase/util': 1.13.0 + '@firebase/app-types': 0.9.4 + '@firebase/util': 1.15.0 - '@firebase/database@1.1.0': + '@firebase/database@1.1.2': dependencies: '@firebase/app-check-interop-types': 0.3.3 '@firebase/auth-interop-types': 0.2.4 - '@firebase/component': 0.7.0 + '@firebase/component': 0.7.2 '@firebase/logger': 0.5.0 - '@firebase/util': 1.13.0 + '@firebase/util': 1.15.0 faye-websocket: 0.11.4 tslib: 2.8.1 @@ -7032,17 +7015,17 @@ snapshots: dependencies: tslib: 2.8.1 - '@firebase/util@1.13.0': + '@firebase/util@1.15.0': dependencies: tslib: 2.8.1 '@google-cloud/firestore@7.11.6': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 fast-deep-equal: 3.1.3 functional-red-black-tree: 1.0.1 google-gax: 4.6.1 - protobufjs: 7.5.4 + protobufjs: 7.5.5 transitivePeerDependencies: - encoding - supports-color @@ -7060,7 +7043,7 @@ snapshots: '@google-cloud/promisify@4.0.0': optional: true - '@google-cloud/storage@7.17.2': + '@google-cloud/storage@7.19.0': dependencies: '@google-cloud/paginator': 5.0.2 '@google-cloud/projectify': 4.0.0 @@ -7068,7 +7051,7 @@ snapshots: abort-controller: 3.0.0 async-retry: 1.3.3 duplexify: 4.1.3 - fast-xml-parser: 4.5.3 + fast-xml-parser: 5.7.1 gaxios: 6.7.1 google-auth-library: 9.15.1 html-entities: 2.6.0 @@ -7082,25 +7065,25 @@ snapshots: - supports-color optional: true - '@graphql-tools/merge@9.1.7(graphql@16.12.0)': + '@graphql-tools/merge@9.1.8(graphql@16.13.2)': dependencies: - '@graphql-tools/utils': 11.0.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/utils': 11.1.0(graphql@16.13.2) + graphql: 16.13.2 tslib: 2.8.1 - '@graphql-tools/utils@11.0.0(graphql@16.12.0)': + '@graphql-tools/utils@11.1.0(graphql@16.13.2)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) '@whatwg-node/promise-helpers': 1.3.2 cross-inspect: 1.0.1 - graphql: 16.12.0 + graphql: 16.13.2 tslib: 2.8.1 - '@graphql-typed-document-node/core@3.2.0(graphql@16.12.0)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.13.2)': dependencies: - graphql: 16.12.0 + graphql: 16.13.2 - '@grpc/grpc-js@1.14.0': + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.0 '@js-sdsl/ordered-map': 4.4.2 @@ -7110,7 +7093,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.5.4 + protobufjs: 7.5.5 yargs: 17.7.2 optional: true @@ -7118,7 +7101,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.5.4 + protobufjs: 7.5.5 yargs: 17.7.2 optional: true @@ -7137,128 +7120,128 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@24.10.13)': + '@inquirer/checkbox@4.3.2(@types/node@24.10.15)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.15) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/confirm@5.1.21(@types/node@24.10.13)': + '@inquirer/confirm@5.1.21(@types/node@24.10.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) + '@inquirer/type': 3.0.10(@types/node@24.10.15) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/core@10.3.2(@types/node@24.10.13)': + '@inquirer/core@10.3.2(@types/node@24.10.15)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.15) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/editor@4.2.23(@types/node@24.10.13)': + '@inquirer/editor@4.2.23(@types/node@24.10.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/external-editor': 1.0.3(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) + '@inquirer/external-editor': 1.0.3(@types/node@24.10.15) + '@inquirer/type': 3.0.10(@types/node@24.10.15) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/expand@4.0.23(@types/node@24.10.13)': + '@inquirer/expand@4.0.23(@types/node@24.10.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) + '@inquirer/type': 3.0.10(@types/node@24.10.15) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/external-editor@1.0.3(@types/node@24.10.13)': + '@inquirer/external-editor@1.0.3(@types/node@24.10.15)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@24.10.13)': + '@inquirer/input@4.3.1(@types/node@24.10.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) + '@inquirer/type': 3.0.10(@types/node@24.10.15) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/number@3.0.23(@types/node@24.10.13)': + '@inquirer/number@3.0.23(@types/node@24.10.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) + '@inquirer/type': 3.0.10(@types/node@24.10.15) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/password@4.0.23(@types/node@24.10.13)': + '@inquirer/password@4.0.23(@types/node@24.10.15)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) + '@inquirer/type': 3.0.10(@types/node@24.10.15) optionalDependencies: - '@types/node': 24.10.13 - - '@inquirer/prompts@7.10.1(@types/node@24.10.13)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.10.13) - '@inquirer/confirm': 5.1.21(@types/node@24.10.13) - '@inquirer/editor': 4.2.23(@types/node@24.10.13) - '@inquirer/expand': 4.0.23(@types/node@24.10.13) - '@inquirer/input': 4.3.1(@types/node@24.10.13) - '@inquirer/number': 3.0.23(@types/node@24.10.13) - '@inquirer/password': 4.0.23(@types/node@24.10.13) - '@inquirer/rawlist': 4.1.11(@types/node@24.10.13) - '@inquirer/search': 3.2.2(@types/node@24.10.13) - '@inquirer/select': 4.4.2(@types/node@24.10.13) + '@types/node': 24.10.15 + + '@inquirer/prompts@7.10.1(@types/node@24.10.15)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.10.15) + '@inquirer/confirm': 5.1.21(@types/node@24.10.15) + '@inquirer/editor': 4.2.23(@types/node@24.10.15) + '@inquirer/expand': 4.0.23(@types/node@24.10.15) + '@inquirer/input': 4.3.1(@types/node@24.10.15) + '@inquirer/number': 3.0.23(@types/node@24.10.15) + '@inquirer/password': 4.0.23(@types/node@24.10.15) + '@inquirer/rawlist': 4.1.11(@types/node@24.10.15) + '@inquirer/search': 3.2.2(@types/node@24.10.15) + '@inquirer/select': 4.4.2(@types/node@24.10.15) optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/rawlist@4.1.11(@types/node@24.10.13)': + '@inquirer/rawlist@4.1.11(@types/node@24.10.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) + '@inquirer/type': 3.0.10(@types/node@24.10.15) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/search@3.2.2(@types/node@24.10.13)': + '@inquirer/search@3.2.2(@types/node@24.10.15)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.15) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/select@4.4.2(@types/node@24.10.13)': + '@inquirer/select@4.4.2(@types/node@24.10.15)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/type': 3.0.10(@types/node@24.10.15) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@inquirer/type@3.0.10(@types/node@24.10.13)': + '@inquirer/type@3.0.10(@types/node@24.10.15)': optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 '@ioredis/commands@1.5.0': {} @@ -7271,8 +7254,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/cliui@9.0.0': {} - '@istanbuljs/schema@0.1.3': {} '@jridgewell/gen-mapping@0.3.13': @@ -7324,6 +7305,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nodable/entities@2.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7334,7 +7317,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 '@octokit/auth-token@5.1.2': {} @@ -7408,6 +7391,9 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': + optional: true + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -7415,27 +7401,28 @@ snapshots: '@pkgr/core@0.2.9': {} - '@prefabs.tech/eslint-config@0.5.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)': + '@prefabs.tech/eslint-config@0.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3)(typescript@5.9.3)': dependencies: - '@eslint/js': 9.39.2 - eslint: 9.39.2(jiti@2.6.1) - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + '@eslint/js': 9.39.4 + eslint: 9.39.4(jiti@2.6.1) + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0) - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-n: 17.20.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) - eslint-plugin-promise: 7.2.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-unicorn: 62.0.0(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-vue: 10.7.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-n: 17.20.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-perfectionist: 5.9.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3) + eslint-plugin-promise: 7.2.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-unicorn: 62.0.0(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-vue: 10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.4(jiti@2.6.1))) globals: 17.3.0 - prettier: 3.8.1 + prettier: 3.8.3 typescript: 5.9.3 - typescript-eslint: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) + typescript-eslint: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.2.0(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - '@stylistic/eslint-plugin' - '@types/eslint' @@ -7446,16 +7433,12 @@ snapshots: '@prefabs.tech/postgres-migrations@5.4.3': dependencies: - pg: 8.18.0 + pg: 8.20.0 sql-template-strings: 2.2.2 transitivePeerDependencies: - pg-native - '@prefabs.tech/tsconfig@0.5.0(@types/node@24.10.13)': - dependencies: - '@vue/tsconfig': 0.1.3(@types/node@24.10.13) - transitivePeerDependencies: - - '@types/node' + '@prefabs.tech/tsconfig@0.7.0': {} '@protobufjs/aspromise@1.1.2': optional: true @@ -7463,7 +7446,7 @@ snapshots: '@protobufjs/base64@1.1.2': optional: true - '@protobufjs/codegen@2.0.4': + '@protobufjs/codegen@2.0.5': optional: true '@protobufjs/eventemitter@1.1.0': @@ -7472,13 +7455,13 @@ snapshots: '@protobufjs/fetch@1.1.0': dependencies: '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 + '@protobufjs/inquire': 1.1.1 optional: true '@protobufjs/float@1.0.2': optional: true - '@protobufjs/inquire@1.1.0': + '@protobufjs/inquire@1.1.1': optional: true '@protobufjs/path@1.1.2': @@ -7487,7 +7470,7 @@ snapshots: '@protobufjs/pool@1.1.0': optional: true - '@protobufjs/utf8@1.1.0': + '@protobufjs/utf8@1.1.1': optional: true '@rollup/rollup-android-arm-eabi@4.52.5': @@ -7565,6 +7548,12 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + '@sindresorhus/merge-streams@2.3.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -7574,8 +7563,8 @@ snapshots: '@slack/webhook@7.0.7': dependencies: '@slack/types': 2.20.0 - '@types/node': 24.10.13 - axios: 1.13.5 + '@types/node': 24.10.15 + axios: 1.13.5(debug@4.4.3) transitivePeerDependencies: - debug @@ -7600,8 +7589,8 @@ snapshots: '@slonik/sql-tag': 46.8.0(zod@3.25.76) '@slonik/types': 46.8.0(zod@3.25.76) '@slonik/utilities': 46.8.0(zod@3.25.76) - pg: 8.18.0 - pg-query-stream: 4.10.3(pg@8.18.0) + pg: 8.20.0 + pg-query-stream: 4.10.3(pg@8.20.0) pg-types: 4.1.0 postgres-array: 3.0.4 zod: 3.25.76 @@ -7627,258 +7616,250 @@ snapshots: '@slonik/types': 46.8.0(zod@3.25.76) zod: 3.25.76 - '@smithy/abort-controller@4.2.8': + '@smithy/chunked-blob-reader-native@4.2.3': dependencies: - '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.2 tslib: 2.8.1 - '@smithy/chunked-blob-reader-native@4.2.1': + '@smithy/chunked-blob-reader@5.2.2': dependencies: - '@smithy/util-base64': 4.3.0 tslib: 2.8.1 - '@smithy/chunked-blob-reader@5.2.0': + '@smithy/config-resolver@4.4.17': dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 - '@smithy/config-resolver@4.4.6': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - - '@smithy/core@3.23.0': - dependencies: - '@smithy/middleware-serde': 4.2.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/uuid': 1.1.0 + '@smithy/core@3.23.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.8': + '@smithy/credential-provider-imds@4.2.14': dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.8': + '@smithy/eventstream-codec@4.2.14': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.8': + '@smithy/eventstream-serde-browser@4.2.14': dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.8': + '@smithy/eventstream-serde-config-resolver@4.3.14': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.8': + '@smithy/eventstream-serde-node@4.2.14': dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.8': + '@smithy/eventstream-serde-universal@4.2.14': dependencies: - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.9': + '@smithy/fetch-http-handler@5.3.17': dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 tslib: 2.8.1 - '@smithy/hash-blob-browser@4.2.9': + '@smithy/hash-blob-browser@4.2.15': dependencies: - '@smithy/chunked-blob-reader': 5.2.0 - '@smithy/chunked-blob-reader-native': 4.2.1 - '@smithy/types': 4.12.0 + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/hash-node@4.2.8': + '@smithy/hash-node@4.2.14': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/hash-stream-node@4.2.8': + '@smithy/hash-stream-node@4.2.14': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.8': + '@smithy/invalid-dependency@4.2.14': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.0': + '@smithy/is-array-buffer@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/md5-js@4.2.8': + '@smithy/md5-js@4.2.14': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/middleware-content-length@4.2.8': + '@smithy/middleware-content-length@4.2.14': dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.14': + '@smithy/middleware-endpoint@4.4.32': dependencies: - '@smithy/core': 3.23.0 - '@smithy/middleware-serde': 4.2.9 - '@smithy/node-config-provider': 4.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-middleware': 4.2.8 + '@smithy/core': 3.23.17 + '@smithy/middleware-serde': 4.2.20 + '@smithy/node-config-provider': 4.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-middleware': 4.2.14 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.31': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/uuid': 1.1.0 + '@smithy/middleware-retry@4.5.7': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/service-error-classification': 4.3.1 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.9': + '@smithy/middleware-serde@4.2.20': dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/middleware-stack@4.2.8': + '@smithy/middleware-stack@4.2.14': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/node-config-provider@4.3.8': + '@smithy/node-config-provider@4.3.14': dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.10': + '@smithy/node-http-handler@4.6.1': dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/property-provider@4.2.8': + '@smithy/property-provider@4.2.14': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/protocol-http@5.3.8': + '@smithy/protocol-http@5.3.14': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/querystring-builder@4.2.8': + '@smithy/querystring-builder@4.2.14': dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-uri-escape': 4.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-uri-escape': 4.2.2 tslib: 2.8.1 - '@smithy/querystring-parser@4.2.8': + '@smithy/querystring-parser@4.2.14': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/service-error-classification@4.2.8': + '@smithy/service-error-classification@4.3.1': dependencies: - '@smithy/types': 4.12.0 - - '@smithy/shared-ini-file-loader@4.4.3': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 + '@smithy/types': 4.14.1 - '@smithy/signature-v4@5.3.8': + '@smithy/shared-ini-file-loader@4.4.9': dependencies: - '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-uri-escape': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/smithy-client@4.11.3': + '@smithy/signature-v4@5.3.14': dependencies: - '@smithy/core': 3.23.0 - '@smithy/middleware-endpoint': 4.4.14 - '@smithy/middleware-stack': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/types@4.12.0': + '@smithy/smithy-client@4.12.13': dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-stack': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 tslib: 2.8.1 - '@smithy/types@4.8.0': + '@smithy/types@4.14.1': dependencies: tslib: 2.8.1 - '@smithy/url-parser@4.2.8': + '@smithy/url-parser@4.2.14': dependencies: - '@smithy/querystring-parser': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/querystring-parser': 4.2.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-base64@4.3.0': + '@smithy/util-base64@4.3.2': dependencies: - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.0': + '@smithy/util-body-length-browser@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.1': + '@smithy/util-body-length-node@4.2.3': dependencies: tslib: 2.8.1 @@ -7887,65 +7868,65 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': + '@smithy/util-buffer-from@4.2.2': dependencies: - '@smithy/is-array-buffer': 4.2.0 + '@smithy/is-array-buffer': 4.2.2 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.0': + '@smithy/util-config-provider@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.30': + '@smithy/util-defaults-mode-browser@4.3.49': dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.33': + '@smithy/util-defaults-mode-node@4.2.54': dependencies: - '@smithy/config-resolver': 4.4.6 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.3 - '@smithy/types': 4.12.0 + '@smithy/config-resolver': 4.4.17 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-endpoints@3.2.8': + '@smithy/util-endpoints@3.4.2': dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': + '@smithy/util-hex-encoding@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-middleware@4.2.8': + '@smithy/util-middleware@4.2.14': dependencies: - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-retry@4.2.8': + '@smithy/util-retry@4.3.8': dependencies: - '@smithy/service-error-classification': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/service-error-classification': 4.3.1 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.12': + '@smithy/util-stream@4.5.25': dependencies: - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.0': + '@smithy/util-uri-escape@4.2.2': dependencies: tslib: 2.8.1 @@ -7954,22 +7935,39 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.0': + '@smithy/util-utf8@4.2.2': dependencies: - '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-buffer-from': 4.2.2 tslib: 2.8.1 - '@smithy/util-waiter@4.2.8': + '@smithy/util-waiter@4.3.0': dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/types': 4.14.1 tslib: 2.8.1 - '@smithy/uuid@1.1.0': + '@smithy/uuid@1.1.2': dependencies: tslib: 2.8.1 - '@tootallnate/once@2.0.0': + '@tootallnate/once@2.0.1': + optional: true + + '@turbo/darwin-64@2.9.9': + optional: true + + '@turbo/darwin-arm64@2.9.9': + optional: true + + '@turbo/linux-64@2.9.9': + optional: true + + '@turbo/linux-arm64@2.9.9': + optional: true + + '@turbo/windows-64@2.9.9': + optional: true + + '@turbo/windows-arm64@2.9.9': optional: true '@tybys/wasm-util@0.10.1': @@ -7977,14 +7975,9 @@ snapshots: tslib: 2.8.1 optional: true - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 24.10.13 - '@types/busboy@1.5.4': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 '@types/caseless@0.12.5': optional: true @@ -7994,32 +7987,12 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/connect@3.4.38': - dependencies: - '@types/node': 24.10.13 - '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} - '@types/express-serve-static-core@4.19.7': - dependencies: - '@types/node': 24.10.13 - '@types/qs': 6.14.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@4.17.24': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.7 - '@types/qs': 6.14.0 - '@types/serve-static': 1.15.10 - '@types/html-to-text@9.0.4': {} - '@types/http-errors@2.0.5': {} - '@types/humps@2.0.6': {} '@types/json-schema@7.0.15': {} @@ -8029,13 +8002,11 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 24.10.13 + '@types/node': 24.10.15 '@types/long@4.0.2': optional: true - '@types/mime@1.3.5': {} - '@types/mjml-core@4.15.2': {} '@types/mjml@4.7.4': @@ -8044,58 +8015,35 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@22.18.12': - dependencies: - undici-types: 6.21.0 - - '@types/node@24.10.13': + '@types/node@24.10.15': dependencies: undici-types: 7.16.0 '@types/nodemailer-html-to-text@3.1.3': dependencies: '@types/html-to-text': 9.0.4 - '@types/nodemailer': 6.4.22 + '@types/nodemailer': 6.4.23 - '@types/nodemailer@6.4.22': + '@types/nodemailer@6.4.23': dependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 - '@types/pg@8.16.0': + '@types/pg@8.20.0': dependencies: - '@types/node': 24.10.13 - pg-protocol: 1.10.3 + '@types/node': 24.10.15 + pg-protocol: 1.13.0 pg-types: 2.2.0 - '@types/qs@6.14.0': {} - - '@types/range-parser@1.2.7': {} - '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 - '@types/node': 24.10.13 + '@types/node': 24.10.15 '@types/tough-cookie': 4.0.5 form-data: 2.5.5 optional: true '@types/semver@7.7.1': {} - '@types/send@0.17.6': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 24.10.13 - - '@types/send@1.2.1': - dependencies: - '@types/node': 24.10.13 - - '@types/serve-static@1.15.10': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 24.10.13 - '@types/send': 0.17.6 - '@types/stack-trace@0.0.33': {} '@types/tough-cookie@4.0.5': @@ -8105,96 +8053,96 @@ snapshots: '@types/validator@13.15.10': {} - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.54.0': + '@typescript-eslint/scope-manager@8.58.0': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.54.0': {} + '@typescript-eslint/types@8.58.0': {} - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.54.0': + '@typescript-eslint/visitor-keys@8.58.0': dependencies: - '@typescript-eslint/types': 8.54.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -8255,7 +8203,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1))': + '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1))': dependencies: '@istanbuljs/schema': 0.1.3 debug: 4.4.3 @@ -8267,7 +8215,7 @@ snapshots: magicast: 0.3.5 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -8279,13 +8227,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + vite: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -8313,10 +8261,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vue/tsconfig@0.1.3(@types/node@24.10.13)': - optionalDependencies: - '@types/node': 24.10.13 - '@whatwg-node/promise-helpers@1.3.2': dependencies: tslib: 2.8.1 @@ -8352,19 +8296,19 @@ snapshots: ajv-formats@3.0.1: dependencies: - ajv: 8.17.1 + ajv: 8.20.0 - ajv@6.12.6: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.1 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -8488,22 +8432,14 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - avvio@9.1.0: + avvio@9.2.0: dependencies: '@fastify/error': 4.2.0 - fastq: 1.19.1 + fastq: 1.20.1 axe-core@4.11.1: {} - axios@1.12.2(debug@4.4.3): - dependencies: - follow-redirects: 1.15.11(debug@4.4.3) - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - axios@1.13.5: + axios@1.13.5(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) form-data: 4.0.5 @@ -8515,9 +8451,7 @@ snapshots: balanced-match@1.0.2: {} - balanced-match@4.0.2: - dependencies: - jackspeak: 4.2.3 + balanced-match@4.0.4: {} base64-js@1.5.1: {} @@ -8533,7 +8467,7 @@ snapshots: boolbase@1.0.0: {} - bowser@2.12.1: {} + bowser@2.14.1: {} brace-expansion@1.1.12: dependencies: @@ -8544,9 +8478,9 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.2: + brace-expansion@5.0.5: dependencies: - balanced-match: 4.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -8744,7 +8678,11 @@ snapshots: dependencies: compare-func: 2.0.0 - conventional-changelog-conventionalcommits@9.1.0: + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: dependencies: compare-func: 2.0.0 @@ -8767,7 +8705,7 @@ snapshots: conventional-changelog-writer@8.2.0: dependencies: conventional-commits-filter: 5.0.0 - handlebars: 4.7.8 + handlebars: 4.7.9 meow: 13.2.0 semver: 7.7.4 @@ -8777,24 +8715,29 @@ snapshots: dependencies: meow: 13.2.0 + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + convert-source-map@2.0.0: {} cookie@0.4.0: {} - cookie@1.0.2: {} + cookie@1.1.1: {} core-js-compat@3.48.0: dependencies: browserslist: 4.28.1 - cosmiconfig-typescript-loader@6.2.0(@types/node@24.10.13)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@24.10.15)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 24.10.13 - cosmiconfig: 9.0.0(typescript@5.9.3) + '@types/node': 24.10.15 + cosmiconfig: 9.0.1(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 - cosmiconfig@9.0.0(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 @@ -8839,10 +8782,10 @@ snapshots: damerau-levenshtein@1.0.8: {} - dargs@8.1.0: {} - data-uri-to-buffer@2.0.2: {} + data-uri-to-buffer@4.0.1: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -8985,7 +8928,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.7.4 + semver: 7.7.3 ejs@3.1.10: dependencies: @@ -9044,7 +8987,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -9115,7 +9058,7 @@ snapshots: es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 es-to-primitive@1.3.0: dependencies: @@ -9123,6 +9066,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.46.1: {} + esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -9162,14 +9107,14 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-compat-utils@0.5.1(eslint@9.39.2(jiti@2.6.1)): + eslint-compat-utils@0.5.1(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: @@ -9180,7 +9125,7 @@ snapshots: eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0): dependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)) eslint-import-resolver-node@0.3.9: dependencies: @@ -9190,10 +9135,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.13.1 is-bun-module: 2.0.0 @@ -9201,29 +9146,29 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-es-x@7.8.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-es-x@7.8.0(eslint@9.39.4(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - eslint: 9.39.2(jiti@2.6.1) - eslint-compat-utils: 0.5.1(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9232,13 +9177,13 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)) - hasown: 2.0.2 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.3 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -9246,13 +9191,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -9262,22 +9207,22 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.2(jiti@2.6.1) - hasown: 2.0.2 + eslint: 9.39.4(jiti@2.6.1) + hasown: 2.0.3 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-n@17.20.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-plugin-n@17.20.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) enhanced-resolve: 5.19.0 - eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-es-x: 7.8.0(eslint@9.39.2(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@9.39.4(jiti@2.6.1)) get-tsconfig: 4.13.1 globals: 15.15.0 ignore: 5.3.2 @@ -9288,32 +9233,41 @@ snapshots: - supports-color - typescript - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): + eslint-plugin-perfectionist@5.9.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))(prettier@3.8.3): dependencies: - eslint: 9.39.2(jiti@2.6.1) - prettier: 3.8.1 + eslint: 9.39.4(jiti@2.6.1) + prettier: 3.8.3 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-promise@7.2.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-promise@7.2.1(eslint@9.39.4(jiti@2.6.1)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/core': 7.28.5 '@babel/parser': 7.28.5 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) hermes-parser: 0.25.1 zod: 3.25.76 zod-validation-error: 4.0.2(zod@3.25.76) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -9321,30 +9275,30 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.2 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.3 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.6 + resolve: 2.0.0-next.5 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-unicorn@62.0.0(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-unicorn@62.0.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint/plugin-kit': 0.4.1 change-case: 5.4.4 ci-info: 4.4.0 clean-regexp: 1.0.0 core-js-compat: 3.48.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) esquery: 1.6.0 find-up-simple: 1.0.1 globals: 16.5.0 @@ -9357,18 +9311,18 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1))): + eslint-plugin-vue@10.7.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.2.0(eslint@9.39.4(jiti@2.6.1))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - eslint: 9.39.2(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.4 - vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) + vue-eslint-parser: 10.2.0(eslint@9.39.4(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -9379,21 +9333,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -9412,7 +9368,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -9488,19 +9444,19 @@ snapshots: fast-json-stringify@5.16.1: dependencies: '@fastify/merge-json-schemas': 0.1.1 - ajv: 8.17.1 + ajv: 8.20.0 ajv-formats: 3.0.1 fast-deep-equal: 3.1.3 fast-uri: 2.4.0 json-schema-ref-resolver: 1.0.1 rfdc: 1.4.1 - fast-json-stringify@6.1.1: + fast-json-stringify@6.3.0: dependencies: '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.17.1 + ajv: 8.20.0 ajv-formats: 3.0.1 - fast-uri: 3.1.0 + fast-uri: 3.1.1 json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 @@ -9518,27 +9474,31 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-parser@4.5.3: + fast-uri@3.1.1: {} + + fast-xml-builder@1.1.7: dependencies: - strnum: 1.1.2 - optional: true + path-expression-matcher: 1.5.0 - fast-xml-parser@5.3.4: + fast-xml-parser@5.7.1: dependencies: - strnum: 2.1.1 + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.7 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 fastify-plugin@5.1.0: {} - fastify@5.7.4: + fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 '@fastify/fast-json-stringify-compiler': 5.0.3 '@fastify/proxy-addr': 5.1.0 abstract-logging: 2.0.1 - avvio: 9.1.0 - fast-json-stringify: 6.1.1 - find-my-way: 9.3.0 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 light-my-request: 6.6.0 pino: 10.3.1 process-warning: 5.0.0 @@ -9556,6 +9516,10 @@ snapshots: dependencies: reusify: 1.1.0 + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + faye-websocket@0.11.4: dependencies: websocket-driver: 0.7.4 @@ -9568,6 +9532,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -9584,11 +9553,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 - find-my-way@9.3.0: + find-my-way@9.5.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 - safe-regex2: 5.0.0 + safe-regex2: 5.1.1 find-up-simple@1.0.1: {} @@ -9597,22 +9566,21 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - firebase-admin@13.6.1: + firebase-admin@13.8.0: dependencies: '@fastify/busboy': 3.2.0 - '@firebase/database-compat': 2.1.0 - '@firebase/database-types': 1.0.16 - '@types/node': 22.18.12 + '@firebase/database-compat': 2.1.3 + '@firebase/database-types': 1.0.19 farmhash-modern: 1.1.0 fast-deep-equal: 3.1.3 - google-auth-library: 9.15.1 - jsonwebtoken: 9.0.2 - jwks-rsa: 3.2.0 - node-forge: 1.3.1 - uuid: 11.1.0 + google-auth-library: 10.6.2 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2 + node-forge: 1.4.0 + uuid: 11.1.1 optionalDependencies: '@google-cloud/firestore': 7.11.6 - '@google-cloud/storage': 7.17.2 + '@google-cloud/storage': 7.19.0 transitivePeerDependencies: - encoding - supports-color @@ -9642,12 +9610,12 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.3 mime-types: 2.1.35 safe-buffer: 5.2.1 optional: true - form-data@4.0.4: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -9655,13 +9623,9 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - form-data@4.0.5: + formdata-polyfill@4.0.10: dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 + fetch-blob: 3.2.0 forwarded@0.2.0: {} @@ -9676,7 +9640,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.3 is-callable: 1.2.7 functional-red-black-tree@1.0.1: {} @@ -9693,6 +9657,15 @@ snapshots: transitivePeerDependencies: - encoding - supports-color + optional: true + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color gcp-metadata@6.1.1: dependencies: @@ -9702,6 +9675,15 @@ snapshots: transitivePeerDependencies: - encoding - supports-color + optional: true + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color generate-function@2.3.1: dependencies: @@ -9755,12 +9737,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - git-raw-commits@4.0.0: - dependencies: - dargs: 8.1.0 - meow: 12.1.1 - split2: 4.2.0 - git-raw-commits@5.0.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1): dependencies: '@conventional-changelog/git-client': 1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1) @@ -9769,6 +9745,14 @@ snapshots: - conventional-commits-filter - conventional-commits-parser + git-raw-commits@5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + git-semver-tags@8.0.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1): dependencies: '@conventional-changelog/git-client': 1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1) @@ -9794,15 +9778,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@13.0.4: + glob@13.0.0: dependencies: - minimatch: 10.2.1 + minimatch: 10.2.5 minipass: 7.1.2 path-scurry: 2.0.0 - global-directory@4.0.1: + global-directory@5.0.0: dependencies: - ini: 4.1.1 + ini: 6.0.0 globals@14.0.0: {} @@ -9826,6 +9810,17 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.3.0 + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 @@ -9833,14 +9828,15 @@ snapshots: gaxios: 6.7.1 gcp-metadata: 6.1.1 gtoken: 7.1.0 - jws: 4.0.0 + jws: 4.0.1 transitivePeerDependencies: - encoding - supports-color + optional: true google-gax@4.6.1: dependencies: - '@grpc/grpc-js': 1.14.0 + '@grpc/grpc-js': 1.14.3 '@grpc/proto-loader': 0.7.15 '@types/long': 4.0.2 abort-controller: 3.0.0 @@ -9849,7 +9845,7 @@ snapshots: node-fetch: 2.7.0 object-hash: 3.0.0 proto3-json-serializer: 2.0.2 - protobufjs: 7.5.4 + protobufjs: 7.5.5 retry-request: 7.0.2 uuid: 9.0.1 transitivePeerDependencies: @@ -9857,43 +9853,47 @@ snapshots: - supports-color optional: true - google-logging-utils@0.0.2: {} + google-logging-utils@0.0.2: + optional: true + + google-logging-utils@1.1.3: {} gopd@1.2.0: {} graceful-fs@4.2.11: {} - graphql-jit@0.8.7(graphql@16.12.0): + graphql-jit@0.8.7(graphql@16.13.2): dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) fast-json-stringify: 5.16.1 generate-function: 2.3.1 - graphql: 16.12.0 + graphql: 16.13.2 lodash.memoize: 4.1.2 lodash.merge: 4.6.2 lodash.mergewith: 4.6.2 - graphql-tag@2.12.6(graphql@16.12.0): + graphql-tag@2.12.6(graphql@16.13.2): dependencies: - graphql: 16.12.0 + graphql: 16.13.2 tslib: 2.8.1 - graphql-upload-minimal@1.6.4(graphql@16.12.0): + graphql-upload-minimal@1.6.4(graphql@16.13.2): dependencies: busboy: 1.6.0 - graphql: 16.12.0 + graphql: 16.13.2 - graphql@16.12.0: {} + graphql@16.13.2: {} gtoken@7.1.0: dependencies: gaxios: 6.7.1 - jws: 4.0.0 + jws: 4.0.1 transitivePeerDependencies: - encoding - supports-color + optional: true - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -9924,6 +9924,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + he@1.2.0: {} hermes-estree@0.25.1: {} @@ -10006,7 +10010,7 @@ snapshots: http-proxy-agent@5.0.0: dependencies: - '@tootallnate/once': 2.0.0 + '@tootallnate/once': 2.0.1 agent-base: 6.0.2 debug: 4.4.3 transitivePeerDependencies: @@ -10068,24 +10072,24 @@ snapshots: ini@1.3.8: {} - ini@4.1.1: {} + ini@6.0.0: {} - inquirer@12.11.1(@types/node@24.10.13): + inquirer@12.11.1(@types/node@24.10.15): dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.10.13) - '@inquirer/prompts': 7.10.1(@types/node@24.10.13) - '@inquirer/type': 3.0.10(@types/node@24.10.13) + '@inquirer/core': 10.3.2(@types/node@24.10.15) + '@inquirer/prompts': 7.10.1(@types/node@24.10.15) + '@inquirer/type': 3.0.10(@types/node@24.10.15) mute-stream: 2.0.0 run-async: 4.0.6 rxjs: 7.8.2 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.3 side-channel: 1.1.0 ioredis@5.9.2: @@ -10102,7 +10106,7 @@ snapshots: transitivePeerDependencies: - supports-color - ipaddr.js@2.2.0: {} + ipaddr.js@2.4.0: {} is-array-buffer@3.0.5: dependencies: @@ -10145,7 +10149,7 @@ snapshots: is-core-module@2.16.1: dependencies: - hasown: 2.0.2 + hasown: 2.0.3 is-data-view@1.0.2: dependencies: @@ -10206,7 +10210,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 is-set@2.0.3: {} @@ -10214,7 +10218,8 @@ snapshots: dependencies: call-bound: 1.0.4 - is-stream@2.0.1: {} + is-stream@2.0.1: + optional: true is-stream@4.0.1: {} @@ -10246,7 +10251,7 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 - is-wsl@3.1.1: + is-wsl@3.1.0: dependencies: is-inside-container: 1.0.0 @@ -10302,10 +10307,6 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jackspeak@4.2.3: - dependencies: - '@isaacs/cliui': 9.0.0 - jake@10.9.4: dependencies: async: 3.2.6 @@ -10382,9 +10383,9 @@ snapshots: jsonify@0.0.1: {} - jsonwebtoken@9.0.2: + jsonwebtoken@9.0.3: dependencies: - jws: 3.2.2 + jws: 4.0.1 lodash.includes: 4.3.0 lodash.isboolean: 3.0.3 lodash.isinteger: 4.0.4 @@ -10412,21 +10413,14 @@ snapshots: transitivePeerDependencies: - encoding - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jwks-rsa@3.2.0: + jwks-rsa@3.2.2: dependencies: - '@types/express': 4.17.24 '@types/jsonwebtoken': 9.0.10 debug: 4.4.3 jose: 4.15.9 @@ -10435,12 +10429,7 @@ snapshots: transitivePeerDependencies: - supports-color - jws@3.2.2: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - - jws@4.0.0: + jws@4.0.1: dependencies: jwa: 2.0.1 safe-buffer: 5.2.1 @@ -10466,9 +10455,9 @@ snapshots: light-my-request@6.6.0: dependencies: - cookie: 1.0.2 + cookie: 1.1.1 process-warning: 4.0.1 - set-cookie-parser: 2.7.1 + set-cookie-parser: 2.7.2 limiter@1.1.5: {} @@ -10478,7 +10467,8 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.camelcase@4.3.0: {} + lodash.camelcase@4.3.0: + optional: true lodash.clonedeep@4.5.0: {} @@ -10498,8 +10488,6 @@ snapshots: lodash.isstring@4.0.1: {} - lodash.kebabcase@4.1.1: {} - lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} @@ -10508,12 +10496,6 @@ snapshots: lodash.once@4.1.1: {} - lodash.snakecase@4.1.1: {} - - lodash.startcase@4.4.0: {} - - lodash.upperfirst@4.3.1: {} - lodash@4.17.21: {} long@5.3.2: @@ -10566,24 +10548,22 @@ snapshots: mensch@0.3.4: {} - meow@12.1.1: {} - meow@13.2.0: {} mercurius-auth@6.0.0: dependencies: '@fastify/error': 4.2.0 fastify-plugin: 5.1.0 - graphql: 16.12.0 + graphql: 16.13.2 - mercurius@16.7.0(graphql@16.12.0): + mercurius@16.9.0(graphql@16.13.2): dependencies: '@fastify/error': 4.2.0 - '@fastify/static': 9.0.0 + '@fastify/static': 9.1.3 '@fastify/websocket': 11.2.0 fastify-plugin: 5.1.0 - graphql: 16.12.0 - graphql-jit: 0.8.7(graphql@16.12.0) + graphql: 16.13.2 + graphql-jit: 0.8.7(graphql@16.13.2) mqemitter: 7.1.0 p-map: 4.0.0 quick-lru: 7.3.0 @@ -10591,8 +10571,8 @@ snapshots: safe-stable-stringify: 2.5.0 secure-json-parse: 4.1.0 single-user-cache: 2.1.0 - tiny-lru: 11.4.5 - ws: 8.18.3 + tiny-lru: 11.4.7 + ws: 8.20.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -10620,11 +10600,11 @@ snapshots: mime@3.0.0: {} - minimatch@10.2.1: + minimatch@10.2.5: dependencies: - brace-expansion: 5.0.2 + brace-expansion: 5.0.5 - minimatch@3.1.2: + minimatch@3.1.5: dependencies: brace-expansion: 1.1.12 @@ -10978,6 +10958,8 @@ snapshots: natural-compare@1.4.0: {} + natural-orderby@5.0.0: {} + nearley@2.20.1: dependencies: commander: 2.20.3 @@ -10995,18 +10977,19 @@ snapshots: node-cron@4.2.1: {} - node-exports-info@1.6.0: - dependencies: - array.prototype.flatmap: 1.3.3 - es-errors: 1.3.0 - object.entries: 1.1.9 - semver: 6.3.1 + node-domexception@1.0.0: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 - node-forge@1.3.1: {} + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-forge@1.4.0: {} node-gyp-build-optional-packages@5.2.2: dependencies: @@ -11157,7 +11140,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -11182,6 +11165,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -11209,15 +11194,15 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.11.0: {} + pg-connection-string@2.12.0: {} - pg-cursor@2.15.3(pg@8.18.0): + pg-cursor@2.15.3(pg@8.20.0): dependencies: - pg: 8.18.0 + pg: 8.20.0 pg-int8@1.0.1: {} - pg-mem@3.0.12(slonik@46.8.0(zod@3.25.76)): + pg-mem@3.0.14(slonik@46.8.0(zod@3.25.76)): dependencies: functional-red-black-tree: 1.0.1 immutable: 4.3.7 @@ -11231,24 +11216,22 @@ snapshots: pg-numeric@1.0.2: {} - pg-pool@3.11.0(pg@8.18.0): + pg-pool@3.13.0(pg@8.20.0): dependencies: - pg: 8.18.0 - - pg-protocol@1.10.3: {} + pg: 8.20.0 - pg-protocol@1.11.0: {} + pg-protocol@1.13.0: {} - pg-query-stream@4.10.3(pg@8.18.0): + pg-query-stream@4.10.3(pg@8.20.0): dependencies: - pg: 8.18.0 - pg-cursor: 2.15.3(pg@8.18.0) + pg: 8.20.0 + pg-cursor: 2.15.3(pg@8.20.0) pg-types@2.2.0: dependencies: pg-int8: 1.0.1 postgres-array: 2.0.0 - postgres-bytea: 1.0.0 + postgres-bytea: 1.0.1 postgres-date: 1.0.7 postgres-interval: 1.2.0 @@ -11262,11 +11245,11 @@ snapshots: postgres-interval: 3.0.0 postgres-range: 1.1.4 - pg@8.18.0: + pg@8.20.0: dependencies: - pg-connection-string: 2.11.0 - pg-pool: 3.11.0(pg@8.18.0) - pg-protocol: 1.11.0 + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -11298,7 +11281,7 @@ snapshots: pino-std-serializers@6.2.2: {} - pino-std-serializers@7.0.0: {} + pino-std-serializers@7.1.0: {} pino@10.3.1: dependencies: @@ -11306,12 +11289,12 @@ snapshots: atomic-sleep: 1.0.0 on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.0.0 + pino-std-serializers: 7.1.0 process-warning: 5.0.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.0 + sonic-boom: 4.2.1 thread-stream: 4.0.0 pino@8.21.0: @@ -11347,7 +11330,7 @@ snapshots: postgres-array@3.0.4: {} - postgres-bytea@1.0.0: {} + postgres-bytea@1.0.1: {} postgres-bytea@3.0.0: dependencies: @@ -11373,7 +11356,7 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.8.1: {} + prettier@3.8.3: {} pretty-ms@7.0.1: dependencies: @@ -11403,22 +11386,22 @@ snapshots: proto3-json-serializer@2.0.2: dependencies: - protobufjs: 7.5.4 + protobufjs: 7.5.5 optional: true - protobufjs@7.5.4: + protobufjs@7.5.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 + '@protobufjs/codegen': 2.0.5 '@protobufjs/eventemitter': 1.1.0 '@protobufjs/fetch': 1.1.0 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 + '@protobufjs/inquire': 1.1.1 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 24.10.13 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.10.15 long: 5.3.2 optional: true @@ -11534,12 +11517,9 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.6: + resolve@2.0.0-next.5: dependencies: - es-errors: 1.3.0 is-core-module: 2.16.1 - node-exports-info: 1.6.0 - object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -11631,7 +11611,7 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - safe-regex2@5.0.0: + safe-regex2@5.1.1: dependencies: ret: 0.5.0 @@ -11653,6 +11633,8 @@ snapshots: semver@7.7.1: {} + semver@7.7.3: {} + semver@7.7.4: {} serialize-error@8.1.0: @@ -11663,7 +11645,7 @@ snapshots: dependencies: randombytes: 2.1.0 - set-cookie-parser@2.7.1: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: dependencies: @@ -11697,13 +11679,13 @@ snapshots: shell-quote@1.8.3: {} - shipjs-lib@0.28.2: + shipjs-lib@0.28.3: dependencies: deepmerge: 4.3.1 execa: 9.6.1 semver: 7.7.1 - shipjs@0.28.2(@types/node@24.10.13)(conventional-commits-filter@5.0.0): + shipjs@0.28.3(@types/node@24.10.15)(conventional-commits-filter@5.0.0): dependencies: '@octokit/rest': 21.1.1 '@slack/webhook': 7.0.7 @@ -11718,12 +11700,12 @@ snapshots: dotenv: 16.6.1 ejs: 3.1.10 globby: 14.1.0 - inquirer: 12.11.1(@types/node@24.10.13) + inquirer: 12.11.1(@types/node@24.10.15) open: 10.2.0 - prettier: 3.8.1 + prettier: 3.8.3 serialize-javascript: 6.0.2 shell-quote: 1.8.3 - shipjs-lib: 0.28.2 + shipjs-lib: 0.28.3 temp-write: 6.0.1 tempfile: 5.0.0 transitivePeerDependencies: @@ -11800,7 +11782,7 @@ snapshots: dependencies: atomic-sleep: 1.0.0 - sonic-boom@4.2.0: + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -11834,7 +11816,7 @@ snapshots: dependencies: type-fest: 0.7.1 - stacktracey@2.1.8: + stacktracey@2.2.0: dependencies: as-table: 1.0.55 get-source: 2.0.12 @@ -11952,10 +11934,7 @@ snapshots: dependencies: js-tokens: 9.0.1 - strnum@1.1.2: - optional: true - - strnum@2.1.1: {} + strnum@2.2.3: {} stubs@3.0.0: optional: true @@ -12030,13 +12009,13 @@ snapshots: dependencies: real-require: 0.2.0 - tiny-lru@11.4.5: {} + tiny-lru@11.4.7: {} tinybench@2.9.0: {} tinyexec@0.3.2: {} - tinyexec@1.0.1: {} + tinyexec@1.1.2: {} tinyglobby@0.2.15: dependencies: @@ -12059,7 +12038,7 @@ snapshots: tr46@0.0.3: {} - ts-api-utils@2.4.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -12077,39 +12056,21 @@ snapshots: tslib@2.8.1: {} - turbo-darwin-64@2.8.7: - optional: true - - turbo-darwin-arm64@2.8.7: - optional: true - - turbo-linux-64@2.8.7: - optional: true - - turbo-linux-arm64@2.8.7: - optional: true - - turbo-windows-64@2.8.7: - optional: true - - turbo-windows-arm64@2.8.7: - optional: true - - turbo@2.8.7: + turbo@2.9.9: optionalDependencies: - turbo-darwin-64: 2.8.7 - turbo-darwin-arm64: 2.8.7 - turbo-linux-64: 2.8.7 - turbo-linux-arm64: 2.8.7 - turbo-windows-64: 2.8.7 - turbo-windows-arm64: 2.8.7 + '@turbo/darwin-64': 2.9.9 + '@turbo/darwin-arm64': 2.9.9 + '@turbo/linux-64': 2.9.9 + '@turbo/linux-arm64': 2.9.9 + '@turbo/windows-64': 2.9.9 + '@turbo/windows-arm64': 2.9.9 twilio@4.23.0(debug@4.4.3): dependencies: - axios: 1.12.2(debug@4.4.3) + axios: 1.13.5(debug@4.4.3) dayjs: 1.11.18 https-proxy-agent: 5.0.1 - jsonwebtoken: 9.0.2 + jsonwebtoken: 9.0.3 qs: 6.14.0 scmp: 2.1.0 url-parse: 1.5.10 @@ -12165,13 +12126,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -12187,8 +12148,6 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.21.0: {} - undici-types@7.16.0: {} unicorn-magic@0.3.0: {} @@ -12248,6 +12207,8 @@ snapshots: uuid@11.1.0: {} + uuid@11.1.1: {} + uuid@8.3.2: optional: true @@ -12260,17 +12221,17 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - validator@13.15.26: {} + validator@13.15.35: {} vary@1.1.2: {} - vite-node@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1): + vite-node@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + vite: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -12285,7 +12246,7 @@ snapshots: - tsx - yaml - vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1): + vite@6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) @@ -12294,16 +12255,16 @@ snapshots: rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 fsevents: 2.3.3 jiti: 2.6.1 yaml: 2.8.1 - vitest@3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1): + vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -12321,11 +12282,11 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.10.13)(jiti@2.6.1)(yaml@2.8.1) + vite: 6.4.2(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.10.15 transitivePeerDependencies: - jiti - less @@ -12340,10 +12301,10 @@ snapshots: - tsx - yaml - vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1)): + vue-eslint-parser@10.2.0(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1) eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 @@ -12365,6 +12326,8 @@ snapshots: transitivePeerDependencies: - encoding + web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} websocket-driver@0.7.4: @@ -12454,11 +12417,11 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.3: {} + ws@8.20.0: {} wsl-utils@0.1.0: dependencies: - is-wsl: 3.1.1 + is-wsl: 3.1.0 xml-name-validator@4.0.0: {} diff --git a/renovate.json b/renovate.json index 39a2b6e9a..4bd832f5f 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base" - ] + "extends": ["config:base"] } diff --git a/ship.config.js b/ship.config.js index 71454c5e0..076972232 100644 --- a/ship.config.js +++ b/ship.config.js @@ -14,11 +14,12 @@ const flatten = (arr) => arr.reduce((acc, item) => acc.concat(item), []); function expandPackageList(list, dir = ".") { const isPackageIgnored = (package) => { - return expandPackageList(list - .filter(value => value.startsWith("!")) - .map(item => item.slice(1)) + return expandPackageList( + list + .filter((value) => value.startsWith("!")) + .map((item) => item.slice(1)), ).includes(package); - } + }; return flatten( list.map((item) => { @@ -49,8 +50,10 @@ function expandPackageList(list, dir = ".") { } else { return resolve(dir, item); } - }) - ).filter((package) => !isPackageIgnored(package)).filter(hasPackageJson) + }), + ) + .filter((package) => !isPackageIgnored(package)) + .filter(hasPackageJson); } // ship.js config @@ -63,5 +66,5 @@ module.exports = { packagesToBump: expandPackageList(["packages/*"]), packagesToPublish: expandPackageList(["packages/*"]), }, - publishCommand: "pnpm publish --access public", + publishCommand: () => "pnpm publish --access public", }; diff --git a/turbo.json b/turbo.json index 776336957..af707e949 100644 --- a/turbo.json +++ b/turbo.json @@ -2,45 +2,27 @@ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - "dist/**" - ] + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "clean": { + "cache": false }, "lint": { - "env": [ - "NODE_ENV" - ], + "env": ["NODE_ENV"], "outputs": [] }, "lint:fix": { "outputs": [] }, - "publish": { - "outputs": [] - }, - "release": { - "outputs": [] - }, "sort-package": { "outputs": [] }, "test": { - "outputs": [] - }, - "test:ci": { - "outputs": [] - }, - "test:integration": { - "outputs": [] - }, - "test:unit": { - "outputs": [] + "outputs": ["coverage/**"] }, "typecheck": { "outputs": [] } } -} \ No newline at end of file +} From 7c638c991fdd9529afca6bc14a1f605933d61abc Mon Sep 17 00:00:00 2001 From: premsgr77 Date: Wed, 13 May 2026 17:20:42 +0545 Subject: [PATCH 22/22] docs(readme): link packages and add error-handler, firebase, swagger, worker --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d93bea1d7..ae90a49ce 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,16 @@ A set of fastify libraries ## Packages -- @prefabs.tech/fastify-config (https://www.npmjs.com/package/@prefabs.tech/fastify-config) -- @prefabs.tech/fastify-graphql (https://www.npmjs.com/package/@prefabs.tech/fastify-graphql) -- @prefabs.tech/fastify-mailer (https://www.npmjs.com/package/@prefabs.tech/fastify-mailer) -- @prefabs.tech/fastify-s3 (https://www.npmjs.com/package/@prefabs.tech/fastify-s3) -- @prefabs.tech/fastify-slonik (https://www.npmjs.com/package/@prefabs.tech/fastify-slonik) -- @prefabs.tech/fastify-user (https://www.npmjs.com/package/@prefabs.tech/fastify-user) +- [@prefabs.tech/fastify-config](https://www.npmjs.com/package/@prefabs.tech/fastify-config) +- [@prefabs.tech/fastify-error-handler](https://www.npmjs.com/package/@prefabs.tech/fastify-error-handler) +- [@prefabs.tech/fastify-firebase](https://www.npmjs.com/package/@prefabs.tech/fastify-firebase) +- [@prefabs.tech/fastify-graphql](https://www.npmjs.com/package/@prefabs.tech/fastify-graphql) +- [@prefabs.tech/fastify-mailer](https://www.npmjs.com/package/@prefabs.tech/fastify-mailer) +- [@prefabs.tech/fastify-s3](https://www.npmjs.com/package/@prefabs.tech/fastify-s3) +- [@prefabs.tech/fastify-slonik](https://www.npmjs.com/package/@prefabs.tech/fastify-slonik) +- [@prefabs.tech/fastify-swagger](https://www.npmjs.com/package/@prefabs.tech/fastify-swagger) +- [@prefabs.tech/fastify-user](https://www.npmjs.com/package/@prefabs.tech/fastify-user) +- [@prefabs.tech/fastify-worker](https://www.npmjs.com/package/@prefabs.tech/fastify-worker) ## Installation & Usage