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 diff --git a/packages/firebase/package.json b/packages/firebase/package.json index 2a5162f84..8bef03c71 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -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 .", diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 6749a51cc..9fc8c4df7 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -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 .", diff --git a/packages/mailer/package.json b/packages/mailer/package.json index a964f8492..96cf25539 100644 --- a/packages/mailer/package.json +++ b/packages/mailer/package.json @@ -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 .", @@ -67,4 +69,4 @@ "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/packages/s3/package.json b/packages/s3/package.json index 93e94442b..5891752ed 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -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 .", diff --git a/packages/slonik/package.json b/packages/slonik/package.json index 10d0692b7..f0c6a1ab5 100644 --- a/packages/slonik/package.json +++ b/packages/slonik/package.json @@ -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 .", diff --git a/packages/user/package.json b/packages/user/package.json index 426528a47..cd34cb69f 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -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 .", diff --git a/packages/worker/.gitignore b/packages/worker/.gitignore new file mode 100644 index 000000000..1d15bf08a --- /dev/null +++ b/packages/worker/.gitignore @@ -0,0 +1,4 @@ +**/*.log* +/coverage +node_modules/ +dist/ 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 new file mode 100644 index 000000000..5b8adb61b --- /dev/null +++ b/packages/worker/README.md @@ -0,0 +1,201 @@ +# @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 (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 + +**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 + +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 }); + + await fastify.register(configPlugin, { config }); + await fastify.register(workerPlugin); + + await fastify.listen({ + port: config.port, + host: "0.0.0.0", + }); +}; + +start(); +``` + +The plugin: + +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. + +### 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 type { FastifyInstance } from "fastify"; + +export const enqueueHello = async (fastify: FastifyInstance) => { + const queue = fastify.worker.adapters.get("bull-queue"); + + if (queue) { + await queue.push({ message: "Hello world!" }); + } +}; +``` + +### Standalone (without Fastify) + +Use `JobOrchestrator` directly when you don't have a Fastify instance: + +```typescript +import { JobOrchestrator } from "@prefabs.tech/fastify-worker"; + +const orchestrator = new JobOrchestrator({ + cronJobs: [ + /* ... */ + ], + queues: [ + /* ... */ + ], +}); + +await orchestrator.start(); + +const queue = orchestrator.adapters.get("bull-queue"); + +await queue?.push({ message: "Hello from a standalone worker" }); + +// On process shutdown: +await orchestrator.shutdown(); +``` + +## Configuration + +Add a `worker` block to your `ApiConfig`: + +```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 * * *", + task: async () => { + console.log("Running daily cleanup..."); + }, + options: { + scheduled: true, + timezone: "UTC", + }, + }, + ], + queues: [ + { + name: "bull-queue", + provider: QueueProvider.BULLMQ, + bullmqConfig: { + handler: async (job) => { + // process the job + }, + queueOptions: { + connection: { + host: "localhost", + port: 6379, + }, + }, + }, + }, + { + name: "sqs-queue", + provider: QueueProvider.SQS, + sqsConfig: { + clientConfig: { + credentials: { + accessKeyId: "", + secretAccessKey: "", + }, + endpoint: "", + region: "", + }, + handler: async (message) => { + // process the message + }, + queueUrl: "", + }, + }, + ], + }, +}; +``` + +### 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/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..e179de8c2 --- /dev/null +++ b/packages/worker/package.json @@ -0,0 +1,70 @@ +{ + "name": "@prefabs.tech/fastify-worker", + "version": "0.94.0", + "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", + "type": "module", + "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", + "test": "vitest run --coverage", + "typecheck": "tsc --noEmit -p tsconfig.json --composite false" + }, + "dependencies": { + "node-cron": "4.2.1" + }, + "devDependencies": { + "@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", + "bullmq": "5.69.3", + "eslint": "9.39.4", + "fastify": "5.8.5", + "fastify-plugin": "5.1.0", + "prettier": "3.8.3", + "typescript": "5.9.3", + "vite": "6.4.2", + "vitest": "3.2.4" + }, + "peerDependencies": { + "@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 new file mode 100644 index 000000000..538c2d2da --- /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 { mockSchedule, mockStop } = vi.hoisted(() => { + const mockStop = vi.fn(); + const mockSchedule = vi.fn().mockReturnValue({ stop: mockStop }); + + return { mockSchedule, mockStop }; +}); + +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 * * * *", options, task }; + + 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..f784be3e1 --- /dev/null +++ b/packages/worker/src/__test__/jobOrchestrator.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { QueueProvider } from "../enum"; +import JobOrchestrator from "../jobOrchestrator"; + +const { mockAdapterShutdown, mockAdapterStart, mockSchedule, mockStopAll } = + vi.hoisted(() => ({ + // 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", () => ({ + 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 }) => ({ + getClient: vi.fn(), + push: vi.fn(), + queueName: config.name, + shutdown: mockAdapterShutdown, + start: mockAdapterStart, + })), + }; +}); + +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); + }); + + 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(); + }); + + it("should create a per-instance AdapterRegistry", () => { + orchestrator = new JobOrchestrator({ cronJobs: [], queues: [] }); + + expect(orchestrator.adapters).toBeDefined(); + expect(orchestrator.adapters.getAll()).toEqual([]); + }); + }); + + 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 per-instance registry", async () => { + orchestrator = new JobOrchestrator({ + queues: [ + { + bullmqConfig: { + handler: vi.fn(), + queueOptions: { connection: {} }, + }, + name: "my-queue", + provider: QueueProvider.BULLMQ, + }, + ], + }); + + await orchestrator.start(); + + expect(orchestrator.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(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 new file mode 100644 index 000000000..b82881fc2 --- /dev/null +++ b/packages/worker/src/__test__/plugin.test.ts @@ -0,0 +1,92 @@ +import type { FastifyInstance } from "fastify"; + +import fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +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 + const mockShutdown = vi.fn().mockResolvedValue(undefined); + const MockJobOrchestrator = vi.fn().mockImplementation(() => ({ + cron: {}, + shutdown: mockShutdown, + start: mockStart, + })); + + return { MockJobOrchestrator, mockShutdown, mockStart }; +}); + +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("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(); + }); + + 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 () => { + 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 new file mode 100644 index 000000000..5e57b38ca --- /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 { + 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", () => { + 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..d21d9cb40 --- /dev/null +++ b/packages/worker/src/__test__/queue/adapters/bullmq.test.ts @@ -0,0 +1,255 @@ +import { Job, JobsOptions } from "bullmq"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import BullMQAdapter from "../../../queue/adapters/bullmq"; + +const { + 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 + 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 { close: mockWorkerClose, on: mockWorkerOn }; + }, + ); + + return { + capturedHandler, + eventListeners, + MockQueue, + mockQueueAdd, + mockQueueClose, + MockWorker, + 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("prefers workerOptions.connection when it differs from queue connection", async () => { + const otherConnection = { host: "other-host", port: 6380 }; + const config = { + ...baseConfig, + workerOptions: { concurrency: 2, connection: otherConnection }, + }; + const overrideAdapter = new BullMQAdapter("override-queue", config); + + await overrideAdapter.start(); + + expect(MockWorker).toHaveBeenCalledWith( + "override-queue", + expect.any(Function), + { concurrency: 2, connection: otherConnection }, + ); + }); + + 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(); + }); + + 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 new file mode 100644 index 000000000..3a5d9f849 --- /dev/null +++ b/packages/worker/src/__test__/queue/adapters/sqs.test.ts @@ -0,0 +1,509 @@ +import { + DeleteMessageCommand, + ReceiveMessageCommand, + SendMessageCommand, +} from "@aws-sdk/client-sqs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SQSAdapter from "../../../queue/adapters/sqs"; + +const { mockClientDestroy, mockClientSend, MockSQSClient } = vi.hoisted(() => { + const mockClientSend = vi.fn(); + const mockClientDestroy = vi.fn(); + const MockSQSClient = vi.fn().mockImplementation(() => ({ + destroy: mockClientDestroy, + send: mockClientSend, + })); + + return { mockClientDestroy, mockClientSend, 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(); + + 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, + 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, + }); + }); + + 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 () => { + // 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(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", () => { + 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(); + 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" }, + { MessageDeduplicationId: "dedup-1", MessageGroupId: "group-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 () => { + 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" }, + ], + }; + } + 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, + }); + + 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) { + return { + Messages: [ + { Body: '{"a":1}', ReceiptHandle: "receipt-handle-a" }, + { Body: '{"b":2}', ReceiptHandle: "receipt-handle-b" }, + ], + }; + } + batchAdapter["isPolling"] = false; + + return {}; + }); + + 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: "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(); + adapter["startPolling"](); + + 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/packages/worker/src/cron/index.ts b/packages/worker/src/cron/index.ts new file mode 100644 index 000000000..d11e20550 --- /dev/null +++ b/packages/worker/src/cron/index.ts @@ -0,0 +1 @@ +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/enum/index.ts b/packages/worker/src/enum/index.ts new file mode 100644 index 000000000..e7d89cd8e --- /dev/null +++ b/packages/worker/src/enum/index.ts @@ -0,0 +1,4 @@ +export enum QueueProvider { + BULLMQ = "bullmq", + SQS = "sqs", +} diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts new file mode 100644 index 000000000..edd5cbd2b --- /dev/null +++ b/packages/worker/src/index.ts @@ -0,0 +1,27 @@ +import "@prefabs.tech/fastify-config"; + +import type JobOrchestrator from "./jobOrchestrator"; + +import { WorkerConfig } from "./types"; + +declare module "@prefabs.tech/fastify-config" { + interface ApiConfig { + worker: WorkerConfig; + } +} + +declare module "fastify" { + interface FastifyInstance { + worker: JobOrchestrator; + } +} + +export * from "./enum"; +export { default as JobOrchestrator } from "./jobOrchestrator"; + +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 new file mode 100644 index 000000000..98093d327 --- /dev/null +++ b/packages/worker/src/jobOrchestrator.ts @@ -0,0 +1,39 @@ +import { CronScheduler } from "./cron"; +import { AdapterRegistry, createQueueAdapter } from "./queue"; +import { WorkerConfig } from "./types"; + +class JobOrchestrator { + 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 { + 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(); + this.adapters.add(adapter); + } + } + } +} + +export default JobOrchestrator; diff --git a/packages/worker/src/plugin.ts b/packages/worker/src/plugin.ts new file mode 100644 index 000000000..24e7c0307 --- /dev/null +++ b/packages/worker/src/plugin.ts @@ -0,0 +1,29 @@ +import { FastifyInstance } from "fastify"; +import FastifyPlugin from "fastify-plugin"; + +import JobOrchestrator from "./jobOrchestrator"; + +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"); + + const jobOrchestrator = new JobOrchestrator(config.worker); + + await jobOrchestrator.start(); + + fastify.decorate("worker", jobOrchestrator); + + fastify.addHook("onClose", async () => { + log.info("Shutting down worker"); + await jobOrchestrator.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..bb1ce37af --- /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) as QueueAdapter | undefined; + } + + 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..84a49d219 --- /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 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 new file mode 100644 index 000000000..99cae845e --- /dev/null +++ b/packages/worker/src/queue/adapters/bullmq.ts @@ -0,0 +1,83 @@ +import { + Queue as BullQueue, + Job, + JobsOptions, + QueueOptions, + Worker, + WorkerOptions, +} from "bullmq"; + +import QueueAdapter from "./base"; + +export interface BullMQAdapterConfig { + handler: (job: Job) => Promise; + onError?: (error: Error) => void; + onFailed?: (job: Job, error: Error) => void; + queueOptions: QueueOptions; + workerOptions?: WorkerOptions; +} + +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, + }; + } + + 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 as 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 && job) { + this.config.onFailed(job as Job, error); + } + }); + } +} + +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..208924c6e --- /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 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 new file mode 100644 index 000000000..7a1d274f5 --- /dev/null +++ b/packages/worker/src/queue/adapters/sqs.ts @@ -0,0 +1,188 @@ +import { + DeleteMessageCommand, + Message, + ReceiveMessageCommand, + ReceiveMessageCommandInput, + SendMessageCommand, + SQSClient, + SQSClientConfig, +} from "@aws-sdk/client-sqs"; + +import QueueAdapter from "./base"; + +export interface SQSAdapterConfig { + clientConfig: SQSClientConfig; + handler: (data: Payload) => Promise; + onError?: (error: Error, message?: Message) => void; + queueUrl: string; + receiveMessageOptions?: ReceiveMessageCommandInput; +} + +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 config: SQSAdapterConfig; + private isPolling: boolean = false; + private pollPromise?: Promise; + private queueUrl: string; + + constructor(name: string, config: SQSAdapterConfig) { + super(name); + + this.config = config; + this.queueUrl = config.queueUrl; + } + + 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(); + } + + async start(): Promise { + this.client = new SQSClient(this.config.clientConfig); + this.startPolling(); + } + + 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; + + 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((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)); + } + } + } + } + + private async processMessage(message: Message): Promise { + let data: Payload; + + try { + if (message.Body === undefined || message.Body === null) { + throw new Error("SQS message has no Body"); + } + + data = JSON.parse(message.Body) as Payload; + } catch (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(); + } +} + +export default SQSAdapter; diff --git a/packages/worker/src/queue/factory.ts b/packages/worker/src/queue/factory.ts new file mode 100644 index 000000000..555410032 --- /dev/null +++ b/packages/worker/src/queue/factory.ts @@ -0,0 +1,35 @@ +import { QueueProvider } from "../enum"; +import { QueueConfig } from "../types"; +import { BullMQAdapter, QueueAdapter, 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 new file mode 100644 index 000000000..6c122100b --- /dev/null +++ b/packages/worker/src/queue/index.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 000000000..7a9f9459c --- /dev/null +++ b/packages/worker/src/types/cron.ts @@ -0,0 +1,7 @@ +import { TaskOptions } from "node-cron"; + +export interface CronJob { + expression: string; + options?: TaskOptions; + task: () => Promise; +} diff --git a/packages/worker/src/types/index.ts b/packages/worker/src/types/index.ts new file mode 100644 index 000000000..098c70183 --- /dev/null +++ b/packages/worker/src/types/index.ts @@ -0,0 +1,10 @@ +import { CronJob } from "./cron"; +import { QueueConfig } from "./queue"; + +export interface WorkerConfig { + cronJobs?: CronJob[]; + queues?: QueueConfig[]; +} + +export * from "./cron"; +export * from "./queue"; diff --git a/packages/worker/src/types/queue.ts b/packages/worker/src/types/queue.ts new file mode 100644 index 000000000..db29f1bde --- /dev/null +++ b/packages/worker/src/types/queue.ts @@ -0,0 +1,10 @@ +import { QueueProvider } from "../enum"; +import { BullMQAdapterConfig } from "../queue/adapters/bullmq"; +import { SQSAdapterConfig } from "../queue/adapters/sqs"; + +export interface QueueConfig { + bullmqConfig?: BullMQAdapterConfig; + name: string; + provider: QueueProvider; + sqsConfig?: SQSAdapterConfig; +} 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..6cbae02ec --- /dev/null +++ b/packages/worker/vite.config.ts @@ -0,0 +1,53 @@ +import { dirname, resolve } 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-graphql": "PrefabsTechFastifyGraphql", + "@prefabs.tech/fastify-slonik": "PrefabsTechFastifySlonik", + 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 72ce9811f..e9cca3071 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -609,6 +609,55 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/worker: + dependencies: + node-cron: + specifier: 4.2.1 + version: 4.2.1 + devDependencies: + '@aws-sdk/client-sqs': + specifier: 3.991.0 + version: 3.991.0 + '@prefabs.tech/eslint-config': + 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.94.0 + version: 0.94.0(fastify-plugin@5.1.0)(fastify@5.8.5) + '@prefabs.tech/tsconfig': + 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.15)(jiti@2.6.1)(yaml@2.8.1)) + bullmq: + specifier: 5.69.3 + version: 5.69.3 + eslint: + specifier: 9.39.4 + version: 9.39.4(jiti@2.6.1) + fastify: + specifier: 5.8.5 + version: 5.8.5 + fastify-plugin: + specifier: 5.1.0 + version: 5.1.0 + prettier: + specifier: 3.8.3 + version: 3.8.3 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + 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.15)(jiti@2.6.1)(yaml@2.8.1) + packages: '@aws-crypto/crc32@5.2.0': @@ -638,6 +687,10 @@ packages: 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/core@3.974.8': resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} engines: {node: '>=20.0.0'} @@ -716,6 +769,10 @@ packages: resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-sqs@3.972.29': + resolution: {integrity: sha512-huMx6RhC/tF9K82GZpnox8vK26Pt+6QASMrJiyup99ffr+HBT3asD4soa9BuD3WeLd64XYdOeuUjIjqKgVu5gA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-ssec@3.972.10': resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} engines: {node: '>=20.0.0'} @@ -744,6 +801,10 @@ packages: resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.11': + resolution: {integrity: sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} @@ -752,6 +813,10 @@ packages: resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} 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-endpoints@3.996.8': resolution: {integrity: sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==} engines: {node: '>=20.0.0'} @@ -1443,13 +1508,8 @@ packages: '@types/node': optional: true - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -1482,6 +1542,36 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1580,6 +1670,13 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@prefabs.tech/eslint-config@0.7.0': + resolution: {integrity: sha512-kMLs+ksinlNKa5FfhTb2qNisnU/On+IGrRHbZ3aHq1INF8NsjVRB7Wm0Q9vdnBipco9YOady0qSx1goVnhhC3w==} + peerDependencies: + eslint: '>=9.0.0' + prettier: '>=3.3.3' + typescript: '>=4.9.5' + '@prefabs.tech/eslint-config@0.8.0': resolution: {integrity: sha512-E1vrgnsjuw9hXanhq18jQFUWrbrmNzr74cND1QA26gqiYKCtnXBol71BYLxJae3SDBPy9eiAyEuqiaep1CHzUg==} peerDependencies: @@ -1587,11 +1684,21 @@ packages: prettier: '>=3.3.3' typescript: '>=4.9.5' + '@prefabs.tech/fastify-config@0.94.0': + resolution: {integrity: sha512-x8d9MSQ/LDbzwN1e8paoH9UNp2Ev0192najA4SyOHACFxAnL0wXF+8Jn4iA3Ebt1Z2xF/RLZVdnWb2ixw+QlBQ==} + engines: {node: '>=20'} + peerDependencies: + fastify: '>=5.2.2' + fastify-plugin: '>=5.0.1' + '@prefabs.tech/postgres-migrations@5.4.3': resolution: {integrity: sha512-9cpcwgI0nZaUWmmybYuqhvroCEZHXy9IQmjdM83UF/yUXNu25ASZ09xfw96Q5Ixq0nYwtqkivorvpayBm+/XXg==} engines: {node: '>10.17.0'} hasBin: true + '@prefabs.tech/tsconfig@0.7.0': + resolution: {integrity: sha512-MiEvKeoNVPSy79tYQOkFeaBoVViW27JYfSiEbCBT+Fvuk1XEkXyBpSxlpdKQDV5bsAg0C5VNSjRh4qH3eDjA+w==} + '@prefabs.tech/tsconfig@0.8.0': resolution: {integrity: sha512-tllO+FL56JLAHE/j6jKbRlUifxIgB2U47vzdL670FpsPsH4vIFNpniW81/pD80ogMeLXqD7E40AHGMTUBaioww==} @@ -1831,6 +1938,10 @@ packages: resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} engines: {node: '>=18.0.0'} + '@smithy/core@3.24.6': + resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.14': resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} engines: {node: '>=18.0.0'} @@ -1951,6 +2062,10 @@ packages: resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.14.3': + resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.14': resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} engines: {node: '>=18.0.0'} @@ -2573,6 +2688,9 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} + bullmq@5.69.3: + resolution: {integrity: sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -2671,6 +2789,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'} @@ -2784,6 +2906,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==} @@ -2884,6 +3010,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'} @@ -2892,6 +3022,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==} @@ -3811,6 +3945,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.9.2: + resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} + engines: {node: '>=12.22.0'} + ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} @@ -4158,9 +4296,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==} @@ -4221,6 +4365,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'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4289,10 +4437,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -4431,6 +4575,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -4466,6 +4617,13 @@ packages: no-case@2.3.2: resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + 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'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -4488,6 +4646,10 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} 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==} @@ -4975,6 +5137,14 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + 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'} @@ -5267,6 +5437,9 @@ packages: stacktracey@2.2.0: resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==} + 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'} @@ -5574,6 +5747,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + 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 @@ -5935,6 +6112,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.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.29 + '@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.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/core@3.974.8': dependencies: '@aws-sdk/types': 3.973.8 @@ -6151,6 +6374,13 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + '@aws-sdk/middleware-sdk-sqs@3.972.29': + dependencies: + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/middleware-ssec@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -6252,6 +6482,11 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/types@3.973.11': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/types@3.973.8': dependencies: '@smithy/types': 4.14.1 @@ -6261,6 +6496,14 @@ snapshots: dependencies: tslib: 2.8.1 + '@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.996.8': dependencies: '@aws-sdk/types': 3.973.8 @@ -6526,7 +6769,7 @@ snapshots: '@conventional-changelog/git-client@1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.1)': dependencies: '@types/semver': 7.7.1 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.2.1 @@ -6686,7 +6929,7 @@ snapshots: dependencies: ajv: 8.20.0 ajv-formats: 3.0.1 - fast-uri: 3.1.0 + fast-uri: 3.1.1 '@fastify/busboy@3.2.0': {} @@ -7044,11 +7287,7 @@ snapshots: optionalDependencies: '@types/node': 24.10.15 - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 + '@ioredis/commands@1.5.0': {} '@isaacs/cliui@8.0.2': dependencies: @@ -7085,6 +7324,24 @@ snapshots: '@lukeed/ms@2.0.2': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -7188,6 +7445,36 @@ snapshots: '@pkgr/core@0.2.9': {} + '@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.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.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.3 + typescript: 5.9.3 + 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' + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + '@prefabs.tech/eslint-config@0.8.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.4 @@ -7218,6 +7505,11 @@ snapshots: - eslint-plugin-import-x - supports-color + '@prefabs.tech/fastify-config@0.94.0(fastify-plugin@5.1.0)(fastify@5.8.5)': + dependencies: + fastify: 5.8.5 + fastify-plugin: 5.1.0 + '@prefabs.tech/postgres-migrations@5.4.3': dependencies: pg: 8.20.0 @@ -7225,6 +7517,8 @@ snapshots: transitivePeerDependencies: - pg-native + '@prefabs.tech/tsconfig@0.7.0': {} + '@prefabs.tech/tsconfig@0.8.0': {} '@protobufjs/aspromise@1.1.2': @@ -7434,6 +7728,12 @@ snapshots: '@smithy/uuid': 1.1.2 tslib: 2.8.1 + '@smithy/core@3.24.6': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.14': dependencies: '@smithy/node-config-provider': 4.3.14 @@ -7630,6 +7930,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.14.3': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.14': dependencies: '@smithy/querystring-parser': 4.2.14 @@ -8311,6 +8615,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 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -8423,6 +8739,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 @@ -8488,7 +8806,7 @@ snapshots: conventional-commits-filter: 5.0.0 handlebars: 4.7.9 meow: 13.2.0 - semver: 7.7.3 + semver: 7.7.4 conventional-commits-filter@5.0.0: {} @@ -8529,6 +8847,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 @@ -8620,10 +8942,15 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} + detect-libc@2.1.2: + optional: true + detect-node@2.1.0: {} discontinuous-range@1.0.0: {} @@ -9228,7 +9555,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.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 @@ -9552,7 +9879,7 @@ snapshots: glob@13.0.0: dependencies: - minimatch: 10.1.1 + minimatch: 10.2.5 minipass: 7.1.2 path-scurry: 2.0.0 @@ -9864,6 +10191,20 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.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.4.0: {} is-array-buffer@3.0.5: @@ -10027,7 +10368,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 @@ -10254,8 +10595,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: {} @@ -10304,6 +10649,8 @@ snapshots: lodash.clonedeep: 4.5.0 lru-cache: 6.0.0 + luxon@3.7.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10316,7 +10663,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 math-intrinsics@1.1.0: {} @@ -10376,10 +10723,6 @@ snapshots: mime@3.0.0: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -10712,6 +11055,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + mustache@4.2.0: {} mute-stream@2.0.0: {} @@ -10737,6 +11096,10 @@ snapshots: dependencies: lower-case: 1.1.4 + node-abort-controller@3.1.1: {} + + node-cron@4.2.1: {} + node-domexception@1.0.0: {} node-fetch@2.7.0: @@ -10751,6 +11114,11 @@ snapshots: node-forge@1.4.0: {} + 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: {} @@ -10778,7 +11146,7 @@ snapshots: normalize-package-data@7.0.1: dependencies: hosted-git-info: 8.1.0 - semver: 7.7.3 + semver: 7.7.4 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -11220,6 +11588,12 @@ snapshots: real-require@0.2.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 @@ -11570,6 +11944,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: {} @@ -11952,6 +12328,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + uuid@11.1.1: {} uuid@8.3.2: