From 877247efd8ea5efb219ea4da402aa33524e65e04 Mon Sep 17 00:00:00 2001 From: Sabyasachi Date: Fri, 14 Aug 2026 11:12:30 +0000 Subject: [PATCH] chore: upgrades and quality gates for 2.16.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @google-cloud/storage 7 -> 8. Verified the eight methods the code calls still exist on the real client, since the suite mocks GCS and cannot catch an API change. Fixes #11: src/types.ts now augments FastifyInstance with FileStore, and index.ts imports it. Nothing referenced types.ts before, so even the existing FastifySchema augmentation never reached consumers. Verified by building wms against a packed candidate: 0 errors across all 23 projects, so the merge with wms's own declaration is safe. ts-jest type checking is on. Removing diagnostics:false surfaced four unsafe casts in the specs. Enabling it needs an explicit types:[jest,node], and warning 151002 is ignored because its suggested fix zeroes function coverage for index.ts. Coverage threshold pinned at 100 so a regression fails rather than being reported and ignored; bail removed so CI shows every failure. ESLint added — there was no linting at all. Type-aware rules on src, relaxed for the provider file and specs where SDK and mock types are any by nature. A MinIO integration test covers the real S3 wire protocol. Skipped unless MINIO_TEST_ENDPOINT is set, so the default suite needs no Docker. The mocked suite could not have caught the NoSuchKey/NotFound mismatch fixed in #9. CI runs lint and build alongside tests; Sonar now runs on pull requests instead of only after merge. Dependabot watches npm, not just actions. Deleted the event-bus plan docs describing a plugin removed in c001efc. skipLibCheck stays on: disabling it fails on thread-stream's .d.ts, not our code. Co-Authored-By: Claude Opus 5 (1M context) --- .github/dependabot.yml | 23 + .github/workflows/build.yml | 1 + .github/workflows/test.yml | 2 + CLAUDE.md | 45 +- CONTRIBUTING.md | 11 +- .../2026-03-20-event-bus-nats-and-tests.md | 365 ----- .../2026-03-20-event-bus-reliability-fixes.md | 1252 ----------------- eslint.config.js | 92 ++ jest.config.js | 14 +- package.json | 13 +- pnpm-lock.yaml | 736 +++++++++- src/file-store.azure.spec.ts | 2 +- src/file-store.local.spec.ts | 9 +- src/file-store.minio.integration.spec.ts | 106 ++ src/file-store.spec.ts | 10 +- src/index.ts | 4 + src/types.ts | 8 + src/utils.ts | 6 +- 18 files changed, 1045 insertions(+), 1654 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 docs/superpowers/plans/2026-03-20-event-bus-nats-and-tests.md delete mode 100644 docs/superpowers/plans/2026-03-20-event-bus-reliability-fixes.md create mode 100644 eslint.config.js create mode 100644 src/file-store.minio.integration.spec.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1bcc807 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + # One PR per ecosystem keeps review manageable; majors stay separate so a + # breaking change is never bundled with routine patches. + aws-sdk: + patterns: ["@aws-sdk/*"] + update-types: ["minor", "patch"] + azure-sdk: + patterns: ["@azure/*"] + update-types: ["minor", "patch"] + dev-tooling: + dependency-type: development + update-types: ["minor", "patch"] + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2b3f00..5f60711 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: push: branches: - main + pull_request: jobs: build: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cce35c4..d4d3dfc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,4 +20,6 @@ jobs: node-version: ${{ env.NODE_VERSION }} cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm run lint + - run: pnpm run build - run: pnpm test diff --git a/CLAUDE.md b/CLAUDE.md index e441a47..b0edab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,12 +7,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Build**: `pnpm run build` — cleans `dist` and compiles TypeScript - **Test**: `pnpm test` — runs the Jest suite - **Test with coverage**: `pnpm run test:coverage` +- **Lint**: `pnpm run lint` (`pnpm run lint:fix` to autofix) +- **Integration test**: `pnpm run test:integration` — needs MinIO, see below - **Format code**: `pnpm run pretty` — Prettier over `src/**` - **Clean**: `pnpm run clean` — removes `dist` - **Transpile**: `pnpm run transpile` — TypeScript compilation only These are the only scripts defined in `package.json`. CI (`.github/workflows/`) runs -`pnpm install --frozen-lockfile` + `pnpm test` on Node 24; the package declares +`pnpm install --frozen-lockfile`, then lint, build and test on Node 24; the package declares `engines: node >=22`. **pnpm only** — never npm or yarn. `pnpm-lock.yaml` is committed and CI installs @@ -30,12 +32,13 @@ plugin for Stackbox applications. - **File Store Plugin** (`src/file-store.ts`): cloud storage abstraction over AWS S3, GCS, Azure Blob Storage, MinIO, and the local filesystem - **`src/utils.ts`**: `streamToBuffer`, safe with both buffer- and string-mode streams -- **`src/types.ts`**: augments `FastifySchema` with `operationId` / `summary` / - `description`; imported for its side effect +- **`src/types.ts`**: augments `FastifySchema` and adds `FastifyInstance.FileStore`. + `index.ts` imports it for its side effect — without that import the augmentations + never reach consumers, since TypeScript only applies them if the declaring file is + pulled into the consumer's compilation. An event-bus plugin (RabbitMQ / GCP Pub/Sub / Azure Service Bus / NATS JetStream) -used to live here and was removed in `c001efc`. Ignore references to it in -`docs/superpowers/plans/` — those are historical planning records, not current design. +used to live here and was removed in `c001efc`. ### File Store System @@ -58,8 +61,23 @@ provider: `file-store.local.spec.ts`, `.gcs.`, `.s3.`, `.azure.`, plus the origi `file-store.spec.ts` and `integration.spec.ts`. Coverage is 100% on statements, branches, functions and lines — keep it there. -Cloud SDKs are mocked with `jest.mock(...)`; only `LocalFileStore` is exercised -against the real filesystem, using temp dirs. No test performs real network I/O. +Cloud SDKs are mocked with `jest.mock(...)`; `LocalFileStore` runs against the real +filesystem in temp dirs. + +`file-store.minio.integration.spec.ts` is the one test that speaks a real S3 wire +protocol. It is skipped unless `MINIO_TEST_ENDPOINT` is set, so the default suite +needs no Docker: + +```bash +docker run -d --name fp-minio -p 19000:9000 \ + -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio:latest server /data +pnpm run test:integration +``` + +Mocked tests cannot catch a behavioural change in an SDK — the NoSuchKey/NotFound +mismatch fixed in #9 was invisible to them. Add to the integration spec when touching +S3 or MinIO behaviour. ## Build Configuration @@ -87,12 +105,17 @@ the issue thread or the PR that resolves it. expand on compile, so coverage gets attributed to imports and comments and reads several points low. `jest.config.js` overrides `sourceMap: true` for the transform only. Do not remove it or coverage numbers become meaningless. -- **`diagnostics: false` in `jest.config.js`** suppresses TypeScript errors during - test runs, which masks real type gaps — see issue #11. +- **ts-jest type checking is on.** `diagnostics: false` used to suppress TypeScript + errors in tests, which is how the missing `FastifyInstance` augmentation went + unnoticed. Only ts-jest config warning 151002 is ignored: the fix it suggests + (`isolatedModules: true`) zeroes function coverage for `index.ts`. +- **`types: ["jest", "node"]`** is set explicitly in the ts-jest tsconfig override. + Without it, enabling diagnostics fails every spec with `Cannot find name 'describe'`. - **Pre-commit hooks**: Husky + lint-staged run Prettier on staged `.js/.ts/.json/.md` files. -- **`tsconfig.build.json` sets `skipLibCheck: true`** — a workaround retained from - when this package depended on `@nats-io/jetstream`. +- **`tsconfig.build.json` sets `skipLibCheck: true`** — still required. Turning it off + fails on `thread-stream@4.2.0` (a pino dependency), whose `.d.ts` references + `TransferListItem`, a name its `worker_threads` types do not export. - **Releasing**: version bumps happen on `main`, in their own commit named just the version (`2.16.0`). Do not put a version bump in a feature PR and do not create tags by hand — publishing is triggered by creating a GitHub Release. See diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1a7213..1831b29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,16 @@ updated lockfile. - `main` requires one approving review, and `require_last_push_approval` is set — any push after an approval dismisses it, so get the branch final before requesting review. -- Keep coverage at 100%; `pnpm run test:coverage` reports it. +- Keep coverage at 100% — `jest.config.js` enforces it as a threshold, so a drop + fails the build rather than being reported and ignored. +- `pnpm run lint` must pass; CI runs lint, build and test. +- Touching S3 or MinIO behaviour? Run the integration suite against real MinIO: + ```bash + docker run -d --name fp-minio -p 19000:9000 \ + -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio:latest server /data + pnpm run test:integration + ``` - Bugs and follow-ups go in **GitHub issues**, not a tracked file in the repo. ## Releases diff --git a/docs/superpowers/plans/2026-03-20-event-bus-nats-and-tests.md b/docs/superpowers/plans/2026-03-20-event-bus-nats-and-tests.md deleted file mode 100644 index c5cc1ea..0000000 --- a/docs/superpowers/plans/2026-03-20-event-bus-nats-and-tests.md +++ /dev/null @@ -1,365 +0,0 @@ -# Event Bus: NATS Driver, Bug Fixes & Comprehensive Integration Tests - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add NATS JetStream driver to the event-bus plugin, fix the pre-existing `convert()` bug, and add comprehensive Docker-based integration tests for all drivers (RabbitMQ, NATS, GCP Pub/Sub emulator, Azure Service Bus emulator). - -**Architecture:** NATS driver follows the same plugin pattern as existing brokers — Fastify plugin for publishing + EventConsumerBuilder for consuming. Integration tests are self-contained: they start Docker containers, run tests, and clean up (including on Ctrl+C / crash). Each driver gets a full test suite: single publish, batch, round-trip with handler, delayed message, error handling. - -**Tech Stack:** TypeScript, Fastify, @nats-io/transport-node, @nats-io/jetstream, @nats-io/nats-core, rabbitmq-client, @google-cloud/pubsub, @azure/service-bus, Jest, Docker (testcontainers not used — direct `docker run` for more control) - -**Constraints:** - -- `EventBus.publish()` stays `void` -- New `busType: "nats-jetstream"` added to union type -- All existing tests must continue to pass - ---- - -## File Structure - -| File | Action | Responsibility | -| ------------------------------------------------ | ---------- | -------------------------------------------------------------- | -| `src/event-bus/interfaces.ts` | **Modify** | Add `"nats-jetstream"` to busType union | -| `src/event-bus/nats-jetstream.ts` | **Create** | NATS JetStream publisher plugin | -| `src/event-bus/event-consumer/nats-jetstream.ts` | **Create** | NATS JetStream consumer | -| `src/event-bus/index.ts` | **Modify** | Add nats-jetstream case to switch | -| `src/event-bus/event-consumer/index.ts` | **Modify** | Add nats-jetstream case to switch | -| `src/event-bus/rabbitmq.ts` | **Modify** | Fix convert() outside try-catch | -| `src/event-bus/azure-servicebus.ts` | **Modify** | Fix convert() outside try-catch, add JSON parse error handling | -| `src/event-bus/event-bus.integration.spec.ts` | **Create** | Docker-based integration tests for all drivers | -| `package.json` | **Modify** | Add NATS dependencies, test:integration script | - ---- - -### Task 1: Fix convert() bug in RabbitMQ and Azure ServiceBus publishers - -**Files:** - -- Modify: `src/event-bus/rabbitmq.ts` -- Modify: `src/event-bus/azure-servicebus.ts` - -The `convert(rawMsg)` call is outside the try-catch block in both message processing routes. A JSON parse error crashes the route handler. Additionally, Azure's `convert()` doesn't wrap `JSON.parse` in try-catch like RabbitMQ's does. - -- [ ] **Step 1: Fix RabbitMQ — move convert() inside try-catch** - -In `src/event-bus/rabbitmq.ts`, the route handler currently does: - -```ts - const msg = convert(rawMsg); // line ~38 — OUTSIDE try-catch - options.validateMsg(msg.event, msg.data, req); - // ... - try { - await selectAndRunHandlers(...) // line ~48 — INSIDE try-catch -``` - -Move `convert()` and `validateMsg()` inside the try-catch, and handle `ErrorWithStatus` from convert: - -Find the route handler's body (after the `if (!rawMsg)` check) and restructure so `convert` is inside the try block. The entire processing block after the null check should be wrapped in try-catch. - -- [ ] **Step 2: Fix Azure — add JSON parse error handling to convert() and move inside try-catch** - -In `src/event-bus/azure-servicebus.ts`, the `convert()` function does `JSON.parse(msg.body)` without try-catch. Add the same pattern as RabbitMQ's convert: - -```ts -function convert(msg: IncomingServiceBusMessage): EventMessage { - let body: MessageBody; - try { - body = JSON.parse(msg.body); - } catch { - throw new ErrorWithStatus( - 400, - `Invalid JSON in message body: ${typeof msg.body === "string" ? msg.body.substring(0, 100) : "non-string"}`, - ); - } - return { - id: "" + msg.messageId, - // ... rest unchanged - }; -} -``` - -Also move the `convert()` call inside the try-catch in the route handler, same as RabbitMQ. - -- [ ] **Step 3: Run tests** - -Run: `npx jest --testPathIgnorePatterns=rabbitmq.spec.ts` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add src/event-bus/rabbitmq.ts src/event-bus/azure-servicebus.ts -git commit -m "fix(event-bus): move convert() inside try-catch, add JSON parse error handling" -``` - ---- - -### Task 2: Add NATS dependencies - -**Files:** - -- Modify: `package.json` - -- [ ] **Step 1: Install NATS packages** - -```bash -pnpm add @nats-io/transport-node @nats-io/jetstream @nats-io/nats-core -``` - -- [ ] **Step 2: Commit** - -```bash -git add package.json pnpm-lock.yaml -git commit -m "chore: add NATS JetStream dependencies" -``` - ---- - -### Task 3: Add "nats-jetstream" to busType and wire up factory - -**Files:** - -- Modify: `src/event-bus/interfaces.ts` -- Modify: `src/event-bus/index.ts` -- Modify: `src/event-bus/event-consumer/index.ts` - -- [ ] **Step 1: Add busType** - -In `src/event-bus/interfaces.ts`, change: - -```ts -busType: "rabbitmq" | "gcp-pubsub" | "azure-servicebus" | "in-process"; -``` - -To: - -```ts -busType: "rabbitmq" | - "gcp-pubsub" | - "azure-servicebus" | - "nats-jetstream" | - "in-process"; -``` - -- [ ] **Step 2: Add case to publisher factory** - -In `src/event-bus/index.ts`, add a case before `default`: - -```ts - case "nats-jetstream": - // eslint-disable-next-line @typescript-eslint/no-require-imports - await f.register(require("./nats-jetstream"), options); - break; -``` - -- [ ] **Step 3: Add case to consumer factory** - -In `src/event-bus/event-consumer/index.ts`, add import: - -```ts -import { NatsJetStreamConsumerBuilder } from "./nats-jetstream"; -``` - -Add case: - -```ts - case "nats-jetstream": - return NatsJetStreamConsumerBuilder(instance); -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/event-bus/interfaces.ts src/event-bus/index.ts src/event-bus/event-consumer/index.ts -git commit -m "feat(event-bus): add nats-jetstream to busType union and factory wiring" -``` - ---- - -### Task 4: Create NATS JetStream publisher plugin - -**Files:** - -- Create: `src/event-bus/nats-jetstream.ts` - -This follows the exact same pattern as the other publishers. Key design decisions: - -- Publishes to subject `{topic}.{event}` (topic from options, event from publish call) -- Message body is JSON `{ event, payload, file, processAfterDelayMs, publishTimestamp }` -- Headers carry metadata: `event`, `file`, `processAfterDelayMs` -- Fire-and-forget to preserve sync `publish()` interface -- Connection error handling via NATS status monitoring -- safeAll for shutdown - -The implementer should read `src/event-bus/rabbitmq.ts` as a reference for the exact pattern, then adapt for NATS JetStream. Key differences from RabbitMQ: - -- Uses `@nats-io/transport-node` `connect()` instead of `rabbitmq-client` `Connection` -- Uses `jetstream(nc)` to get JetStream client -- Publishes via `js.publish(subject, data, { headers })` instead of `publisher.send()` -- Headers use `headers()` from `@nats-io/nats-core` -- Connection env var: `NATS_SERVERS` (comma-separated) -- Topic env var: reuses `options.topic` from EventBusOptions -- Drain connection on close instead of close - -Required env vars: `NATS_SERVERS` -Required options: `topic` (used as the JetStream subject prefix) - -The message processing endpoint should be `/nats-jetstream/process-message` following the existing naming convention. - -- [ ] **Step 1: Create the publisher plugin** - -Create `src/event-bus/nats-jetstream.ts` following the rabbitmq.ts pattern but adapted for NATS JetStream. The file should: - -- Import from `@nats-io/transport-node`, `@nats-io/jetstream`, `@nats-io/nats-core` -- Import safeAll from ./utils -- Use the same `IncomingMessage` / `MessageBody` / `convert()` pattern -- Register `/nats-jetstream/process-message` endpoint -- Fire-and-forget publish with `.catch()` error logging -- safeAll shutdown (drain nc) -- Connection error handling - -- [ ] **Step 2: Verify build compiles** - -Run: `pnpm run build` -Expected: Clean build - -- [ ] **Step 3: Commit** - -```bash -git add src/event-bus/nats-jetstream.ts -git commit -m "feat(event-bus): add NATS JetStream publisher plugin" -``` - ---- - -### Task 5: Create NATS JetStream consumer - -**Files:** - -- Create: `src/event-bus/event-consumer/nats-jetstream.ts` - -Follows the same pattern as the RabbitMQ consumer. Key design: - -- Connects to NATS, gets JetStream consumer by stream + durable name -- Pull-based consume loop with auto-reconnect -- Injects messages to `/nats-jetstream/process-message` endpoint -- Handles status codes: 2xx=ack, 425=nak+delay, 429/409=nak+randomDelay, 5xx=nak -- Abort-aware consume loop with exponential backoff on reconnect -- safeAll shutdown - -Required env vars: `NATS_SERVERS`, `NATS_STREAM`, `NATS_CONSUMER` - -The implementer should read `src/event-bus/event-consumer/rabbitmq.ts` and the NATS driver from the PR diff as references. - -- [ ] **Step 1: Create the consumer** - -Create `src/event-bus/event-consumer/nats-jetstream.ts` following the rabbitmq consumer pattern but adapted for NATS JetStream. - -- [ ] **Step 2: Verify build compiles** - -Run: `pnpm run build` -Expected: Clean build - -- [ ] **Step 3: Run existing tests** - -Run: `npx jest --testPathIgnorePatterns=rabbitmq.spec.ts` -Expected: PASS (all 77 tests) - -- [ ] **Step 4: Commit** - -```bash -git add src/event-bus/event-consumer/nats-jetstream.ts -git commit -m "feat(event-bus): add NATS JetStream consumer" -``` - ---- - -### Task 6: Create comprehensive Docker-based integration tests - -**Files:** - -- Create: `src/event-bus/event-bus.integration.spec.ts` - -This is the big one. Model it after the PR's `drivers.integration.spec.ts` but adapted for the event-bus architecture. The tests are self-contained: - -- Start Docker containers for each broker -- Clean up on exit (including Ctrl+C, crash) -- Test the full round-trip: register plugin → publish event → consumer picks up → handler fires - -**Test structure per driver:** - -1. Single publish + consume round-trip -2. Publish with delay (processAfterDelayMs) — verify 425 then process -3. Handler error retry — verify re-publish with file attribute -4. Multiple handlers for same event -5. Graceful shutdown (close without hanging) - -**Docker images:** - -- RabbitMQ: `rabbitmq:4-alpine` (port 5672) -- NATS: `nats:latest -js` (port 4222) -- GCP Pub/Sub: `google/cloud-sdk:emulators` with `gcloud beta emulators pubsub start` -- Azure Service Bus: `mcr.microsoft.com/azure-messaging/servicebus-emulator` + `mcr.microsoft.com/mssql/server:2022-latest` - -**Infrastructure helpers** (same pattern as PR): - -- `docker()` — execFileSync wrapper -- `dockerRun()` — run container with port mapping -- `dockerStop()` — stop container -- `waitForPort()` — poll until port is ready -- `cleanup()` — stop all containers, restore env vars -- Signal handlers for SIGINT/SIGTERM -- Orphan container cleanup - -The implementer should read the PR's test file thoroughly (ask for it if needed) and adapt for the event-bus architecture. Key differences from the PR: - -- Instead of a `Driver` class with `init()/send()/startReceiving()/close()`, we have Fastify plugins + EventConsumerBuilder -- Tests should use `fastify.register(Plugins.EventBus, options)` + `CreateEventConsumer(fastify, type)` -- Handlers are registered via `options.handlers` -- Events flow through the handler runner (commons.ts) - -- [ ] **Step 1: Create the integration test file** - -The test file should be comprehensive. Add a new npm script: - -```json -"test:integration": "jest event-bus.integration.spec.ts --testTimeout=180000 --forceExit" -``` - -- [ ] **Step 2: Test RabbitMQ (Docker available locally)** - -Run: `npx jest src/event-bus/event-bus.integration.spec.ts --testTimeout=180000 --forceExit -t "RabbitMQ"` -Expected: PASS - -- [ ] **Step 3: Test NATS (Docker available locally)** - -Run: `npx jest src/event-bus/event-bus.integration.spec.ts --testTimeout=180000 --forceExit -t "NATS"` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add src/event-bus/event-bus.integration.spec.ts package.json -git commit -m "test(event-bus): add comprehensive Docker-based integration tests - -Self-contained: starts brokers via Docker, runs tests, cleans up everything. -Handles crashes, Ctrl+C, and leftover containers from failed runs. -Covers: RabbitMQ, NATS JetStream, GCP Pub/Sub emulator, Azure SB emulator." -``` - ---- - -### Task 7: Verify build and all tests - -- [ ] **Step 1: Run build** - Run: `pnpm run build` - -- [ ] **Step 2: Run unit tests** - Run: `pnpm test` - -- [ ] **Step 3: Run integration tests** - Run: `npx jest src/event-bus/event-bus.integration.spec.ts --testTimeout=180000 --forceExit` - -- [ ] **Step 4: Run full RabbitMQ integration tests (existing)** - Run: `npx jest rabbitmq.spec.ts --testTimeout=120000` diff --git a/docs/superpowers/plans/2026-03-20-event-bus-reliability-fixes.md b/docs/superpowers/plans/2026-03-20-event-bus-reliability-fixes.md deleted file mode 100644 index 30a4556..0000000 --- a/docs/superpowers/plans/2026-03-20-event-bus-reliability-fixes.md +++ /dev/null @@ -1,1252 +0,0 @@ -# Event Bus Reliability Fixes — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix message loss, shutdown leaks, and receiver fragility in the event-bus plugin — mirroring the improvements from falcon-wms PR #12730 but adapted for this shared library's architecture. - -**Architecture:** Remove the 20ms flush loops from RabbitMQ and Azure ServiceBus publishers (publish directly, fire-and-forget to preserve the sync `publish()` interface). Fix shutdown ordering (abort before close, `safeAll` for cleanup). Fix receiver bugs (abort-aware sleeps, safe `abandonMessage`, distinct error tags). Add missing RabbitMQ connection error handlers. Guard against resource leaks on init failure. - -**Tech Stack:** TypeScript, Fastify, rabbitmq-client, @azure/service-bus, @google-cloud/pubsub, Jest, testcontainers - -**Constraints:** - -- `EventBus.publish()` signature stays `void` — no breaking API changes -- `mnemonist` `Queue` import can be removed from files that no longer need it -- All existing tests must continue to pass - ---- - -## File Structure - -| File | Action | Responsibility | -| -------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------- | -| `src/event-bus/utils.ts` | **Create** | Shared `safeAll()` utility | -| `src/event-bus/utils.spec.ts` | **Create** | Unit tests for `safeAll()` | -| `src/event-bus/rabbitmq.ts` | **Modify** | Remove flush loop, publish directly, add connection error handler, use `safeAll` in shutdown | -| `src/event-bus/azure-servicebus.ts` | **Modify** | Remove flush loop, publish directly, guard init, use `safeAll` in shutdown | -| `src/event-bus/event-consumer/utils.ts` | **Modify** | Add abort signal support to delay functions | -| `src/event-bus/event-consumer/rabbitmq.ts` | **Modify** | Add connection error handler, use `safeAll` in shutdown | -| `src/event-bus/event-consumer/azure-servicebus.ts` | **Modify** | Fix shutdown-during-receive, distinct error tags, safe `abandonMessage`, guard init | -| `src/event-bus/event-consumer/gcp-pubsub.ts` | **Modify** | Abort-safe reconnect timer, pass abort signal to delay calls | -| `src/event-bus/rabbitmq.spec.ts` | **Modify** | Update tests for direct publish (no more flush wait) | - ---- - -### Task 1: Create shared `safeAll` utility - -**Files:** - -- Create: `src/event-bus/utils.ts` -- Create: `src/event-bus/utils.spec.ts` - -- [ ] **Step 1: Write failing tests for `safeAll`** - -```ts -// src/event-bus/utils.spec.ts -import { safeAll } from "./utils"; - -describe("safeAll", () => { - it("should run all functions even if earlier ones throw", async () => { - const calls: number[] = []; - const errors = await safeAll( - async () => { - calls.push(1); - throw new Error("fail-1"); - }, - async () => { - calls.push(2); - }, - async () => { - calls.push(3); - throw new Error("fail-3"); - }, - ); - expect(calls).toEqual([1, 2, 3]); - expect(errors).toHaveLength(2); - expect((errors[0] as Error).message).toBe("fail-1"); - expect((errors[1] as Error).message).toBe("fail-3"); - }); - - it("should return empty array when all succeed", async () => { - const errors = await safeAll( - async () => {}, - async () => {}, - ); - expect(errors).toEqual([]); - }); - - it("should handle sync functions", async () => { - const calls: number[] = []; - const errors = await safeAll( - () => { - calls.push(1); - }, - () => { - calls.push(2); - throw new Error("sync-fail"); - }, - () => { - calls.push(3); - }, - ); - expect(calls).toEqual([1, 2, 3]); - expect(errors).toHaveLength(1); - }); - - it("should handle empty arguments", async () => { - const errors = await safeAll(); - expect(errors).toEqual([]); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx jest src/event-bus/utils.spec.ts -v` -Expected: FAIL — module not found - -- [ ] **Step 3: Write implementation** - -```ts -// src/event-bus/utils.ts - -/** - * Run cleanup functions sequentially, collecting errors without stopping. - * Sequential (not parallel) because close operations often have ordering - * dependencies (e.g., close sender before client). Every function runs - * even if earlier ones fail. - */ -export async function safeAll( - ...fns: (() => Promise | void)[] -): Promise { - const errors: unknown[] = []; - for (const fn of fns) { - try { - await fn(); - } catch (err) { - errors.push(err); - } - } - return errors; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx jest src/event-bus/utils.spec.ts -v` -Expected: PASS — all 4 tests green - -- [ ] **Step 5: Commit** - -```bash -git add src/event-bus/utils.ts src/event-bus/utils.spec.ts -git commit -m "feat(event-bus): add safeAll utility for safe sequential cleanup" -``` - ---- - -### Task 2: Add abort signal support to consumer delay utilities - -**Files:** - -- Modify: `src/event-bus/event-consumer/utils.ts` - -Currently `exponentialDelay` and `randomDelay` use `timers.setTimeout` without an abort signal. During shutdown, these block until the delay completes. Add an optional `signal` parameter. - -- [ ] **Step 1: Modify delay functions to accept abort signal** - -In `src/event-bus/event-consumer/utils.ts`, change both functions: - -```ts -// Replace existing exponentialDelay signature and body -export async function exponentialDelay( - attempt: number, - baseDelayMs: number = BASE_DELAY, - maxDelayMs: number = MAX_DELAY, - randomizationFactor: number = 0.5, - signal?: AbortSignal, -): Promise { - let delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs); - if (randomizationFactor > 0) { - const randomFactor = (Math.random() - 0.5) * randomizationFactor * delay; - delay = Math.max(0, delay + randomFactor); - } - try { - await timers.setTimeout(delay, undefined, signal ? { signal } : undefined); - } catch (err) { - if ((err as Error).name === "AbortError") return; - throw err; - } -} - -// Replace existing randomDelay signature and body -export async function randomDelay( - max: number = RANDOMIZED_DELAY_MAX, - signal?: AbortSignal, -) { - const delay = Math.ceil(Math.random() * max); - try { - await timers.setTimeout(delay, undefined, signal ? { signal } : undefined); - } catch (err) { - if ((err as Error).name === "AbortError") return; - throw err; - } -} -``` - -- [ ] **Step 2: Run existing tests to verify nothing breaks** - -Run: `npx jest --testPathIgnorePatterns=rabbitmq.spec.ts -v` -Expected: PASS — existing tests still pass (the signal parameter is optional, so existing callers are unaffected) - -- [ ] **Step 3: Commit** - -```bash -git add src/event-bus/event-consumer/utils.ts -git commit -m "feat(event-bus): add abort signal support to delay utilities" -``` - ---- - -### Task 3: Remove flush loop from RabbitMQ publisher — publish directly - -**Files:** - -- Modify: `src/event-bus/rabbitmq.ts` - -This is the core change. Remove `Queue`, `MessageWithAttempts`, `createMessageFlusher`, `flushBatch` — replace with direct publish. The `publish()` method stays sync (fire-and-forget: the async broker call runs but errors are logged, not propagated). - -- [ ] **Step 1: Rewrite `rabbitmq.ts`** - -Replace the entire file content. Key changes: - -1. Remove `mnemonist` import, `MessageWithAttempts` interface, `createMessageFlusher`, `flushBatch` -2. `publishToExchange` now calls `publisher.send(...)` directly (fire-and-forget with `.catch()` for error logging) -3. Add `connection.on("error", ...)` handler to prevent unhandled ECONNRESET crashes -4. Shutdown uses `safeAll` — no more flush step needed since we publish directly -5. Remove the `setInterval` flush loop entirely - -```ts -import { randomUUID } from "crypto"; -import { FastifyPluginAsync, FastifyRequest } from "fastify"; -import fp from "fastify-plugin"; -import { Connection, Publisher } from "rabbitmq-client"; -import { - CreateHandlerRunner, - ErrorWithStatus, - getHandlerMap, - noMatchingHandlers, -} from "./commons"; -import { EventBus, EventBusOptions, EventMessage } from "./interfaces"; -import { - ensureRabbitMqExchangesAndQueues, - getServicePrefix, -} from "./rabbitmq-utils"; -import { safeAll } from "./utils"; - -interface IncomingRabbitMqMessage { - messageId: number; - body: string; -} - -interface MessageBody { - event: string; - payload: any; - file: string | null; - processAfterDelayMs: number | undefined; - publishTimestamp: number; -} - -const plugin: FastifyPluginAsync = async function ( - f, - options, -) { - const handlerMap = getHandlerMap(options); - f.decorate("_hasEventHandlers", handlerMap.size > 0); - - if (!process.env.RABBITMQ_URL) { - throw new Error("RabbitMq requires RABBITMQ_URL"); - } - if (!process.env.K_SERVICE) { - throw new Error("RabbitMq requires K_SERVICE"); - } - const connection = new Connection(process.env.RABBITMQ_URL); - connection.on("error", (err) => { - f.log.error({ tag: "RABBITMQ_CONNECTION_ERROR", err }); - }); - - const service = process.env.K_SERVICE; - if (options.ensureExchangesAndQueues) { - await ensureRabbitMqExchangesAndQueues(connection, service); - } - - const publisher = connection.createPublisher({ maxAttempts: 3 }); - const prefix = getServicePrefix(service); - - f.addHook("onClose", async () => { - f.log.info({ tag: "RABBITMQ_CLOSING" }); - const errors = await safeAll( - () => publisher.close(), - () => connection.close(), - ); - if (errors.length > 0) { - f.log.error({ tag: "RABBITMQ_CLOSE_ERRORS", errors }); - } - }); - - function publishToExchange( - event: string, - payload: any, - file: string | null, - processAfterDelayMs: number, - req?: FastifyRequest, - ) { - options.validateMsg(event, payload, req); - const messageBody: MessageBody = { - event, - payload, - file: file ?? null, - processAfterDelayMs: - processAfterDelayMs > 0 ? processAfterDelayMs : undefined, - publishTimestamp: Date.now(), - }; - - // Fire-and-forget: publish directly, log errors. - // publish() must remain sync (void return) — callers don't await. - publisher - .send( - { - messageId: randomUUID(), - appId: `${prefix}.${service}`, - contentType: "application/json", - durable: true, - exchange: `${prefix}.main-exchange`, - headers: { - event: messageBody.event, - file: messageBody.file, - processAfterDelayMs: "" + messageBody.processAfterDelayMs, - }, - }, - JSON.stringify(messageBody, null, 0), - ) - .catch((err: unknown) => { - f.log.error({ - tag: "RABBITMQ_PUBLISH_ERROR", - err, - event, - }); - }); - - req?.log.info({ - tag: "EVENT_PUBLISH", - event, - payload, - processAfterDelayMs, - }); - } - - const bus: EventBus = { - publish(event, payload, processAfterDelayMs) { - publishToExchange(event, payload, null, processAfterDelayMs ?? 0); - }, - }; - f.decorate("EventBus", { - getter() { - return bus; - }, - }); - - f.decorateRequest("EventBus", { - getter() { - return { - publish: (event, payload, processAfterDelayMs) => { - publishToExchange( - event, - payload, - null, - processAfterDelayMs ?? 0, - this, - ); - }, - }; - }, - }); - - const selectAndRunHandlers = CreateHandlerRunner(f, options, handlerMap); - - f.post<{ Body: IncomingRabbitMqMessage }>( - "/rabbitmq/process-message", - { - schema: { - hide: true, - } as any, - }, - async function (req, reply) { - const rawMsg = req.body; - if (!rawMsg) { - reply.send("OK"); - return reply; - } - req.log.info({ - tag: "RABBITMQ_MESSAGE_RECEIVED", - messageId: rawMsg.messageId, - }); - const msg = convert(rawMsg); - options.validateMsg(msg.event, msg.data, req); - - if (noMatchingHandlers(handlerMap, msg)) { - reply.send("OK"); - return reply; - } - - req.log.info({ - tag: "RABBITMQ_MESSAGE_PROCESSING", - event: msg, - }); - - if ( - msg.processAfterDelayMs > 0 && - Date.now() < msg.publishTime.getTime() + msg.processAfterDelayMs - ) { - reply - .status(425) - .send({ processAfterDelayMs: msg?.processAfterDelayMs }); - return reply; - } - - try { - await selectAndRunHandlers(req, msg, (event, payload, file) => - publishToExchange(event, payload, file, 0, req), - ); - reply.send("OK"); - return reply; - } catch (err) { - if (err instanceof ErrorWithStatus) { - reply.status(err.status).send(err.message); - } else { - reply.status(500).send("ERROR"); - } - return reply; - } - }, - ); -}; - -export = fp(plugin, { name: "fp-eventbus-rabbitmq" }); - -function convert(msg: IncomingRabbitMqMessage): EventMessage { - let body: MessageBody; - try { - body = JSON.parse(msg.body); - } catch { - throw new ErrorWithStatus( - 400, - `Invalid JSON in message body: ${msg.body?.substring(0, 100)}`, - ); - } - return { - id: "" + msg.messageId, - attributes: { - event: body.event, - processAfterDelayMs: "" + (body.processAfterDelayMs ?? 0), - file: body.file ?? "", - }, - data: body.payload, - event: body.event, - processAfterDelayMs: body.processAfterDelayMs ?? 0, - publishTime: new Date(body.publishTimestamp), - }; -} -``` - -- [ ] **Step 2: Run all non-integration tests** - -Run: `npx jest --testPathIgnorePatterns=rabbitmq.spec.ts -v` -Expected: PASS - -- [ ] **Step 3: Run RabbitMQ integration tests** - -Run: `npx jest rabbitmq.spec.ts --testTimeout=120000 -v` -Expected: PASS — note that the test `"should publish messages to RabbitMQ"` previously waited 100ms for the flush. The direct publish should make this test pass faster, but the `setTimeout(100)` is still fine as a safety margin. - -- [ ] **Step 4: Commit** - -```bash -git add src/event-bus/rabbitmq.ts -git commit -m "refactor(event-bus): remove RabbitMQ flush loop, publish directly - -Eliminates the 20ms buffered flush that caused message loss on crash, -gave no delivery confirmation, and added ~200 lines of machinery for -negligible batching benefit. publisher.send() is fire-and-forget to -preserve the sync publish() interface. - -Also adds connection error handler to prevent unhandled ECONNRESET -crashes on broker restart, and uses safeAll for shutdown cleanup." -``` - ---- - -### Task 4: Remove flush loop from Azure ServiceBus publisher — publish directly - -**Files:** - -- Modify: `src/event-bus/azure-servicebus.ts` - -Same treatment as RabbitMQ. Remove `Queue`, `MessageWithAttempts`, `createMessageFlusher`, the `setInterval` loop. Publish directly with fire-and-forget. - -- [ ] **Step 1: Rewrite `azure-servicebus.ts`** - -Key changes: - -1. Remove `mnemonist` import, `MessageWithAttempts`, `createMessageFlusher` -2. Create the sender **after** all validation (guard init failure resource leak) -3. If sender creation fails, close the client -4. `publishToServiceBus` calls `sender.sendMessages()` directly (fire-and-forget via `.catch()`) -5. Shutdown uses `safeAll`, no flush needed - -```ts -import * as AzureIden from "@azure/identity"; -import { - ServiceBusClient, - ServiceBusMessage, - ServiceBusSender, -} from "@azure/service-bus"; -import { FastifyPluginAsync, FastifyRequest } from "fastify"; -import fp from "fastify-plugin"; -import { - CreateHandlerRunner, - ErrorWithStatus, - getHandlerMap, - noMatchingHandlers, -} from "./commons"; -import { EventBus, EventBusOptions, EventMessage } from "./interfaces"; -import { safeAll } from "./utils"; - -interface IncomingServiceBusMessage { - messageId: number; - body: string; - scheduledEnqueueTimeUtc?: string; -} - -interface MessageBody { - event: string; - payload: any; - file: string | null; - processAfterDelayMs: number | undefined; - publishTimestamp: number; -} - -const plugin: FastifyPluginAsync = async function ( - f, - options, -) { - const handlerMap = getHandlerMap(options); - if (!options.namespace) { - throw new Error( - "Azure ServiceBus needs the namespace specified. Use EVENT_NAMESPACE env var", - ); - } - if (!options.topic) { - throw new Error( - "Azure ServiceBus needs the topic specified. Use EVENT_TOPIC env var", - ); - } - - // All validation passed — safe to create resources - const client = new ServiceBusClient( - options.namespace, - new AzureIden.DefaultAzureCredential({}), - {}, - ); - - let sender: ServiceBusSender; - try { - sender = client.createSender(options.topic); - } catch (err) { - await client.close(); - throw err; - } - - f.addHook("onClose", async () => { - f.log.info({ tag: "AZURE_SERVICEBUS_CLOSING" }); - const errors = await safeAll( - () => sender.close(), - () => client.close(), - ); - if (errors.length > 0) { - f.log.error({ tag: "AZURE_SERVICEBUS_CLOSE_ERRORS", errors }); - } - }); - - function publishToServiceBus( - event: string, - payload: any, - file: string | null, - processAfterDelayMs: number, - req?: FastifyRequest, - ) { - options.validateMsg(event, payload, req); - const messageBody: MessageBody = { - event, - payload, - file: file ?? null, - processAfterDelayMs: - processAfterDelayMs > 0 ? processAfterDelayMs : undefined, - publishTimestamp: Date.now(), - }; - const encoded = JSON.stringify(messageBody); - const msg: ServiceBusMessage = { - body: Buffer.from(encoded, "utf8"), - applicationProperties: { - event, - file: file ?? "", - }, - contentType: "application/json", - scheduledEnqueueTimeUtc: - processAfterDelayMs > 0 - ? new Date(messageBody.publishTimestamp + processAfterDelayMs) - : undefined, - }; - - // Fire-and-forget: publish directly, log errors. - sender.sendMessages(msg).catch((err: unknown) => { - f.log.error({ - tag: "AZURE_SERVICEBUS_PUBLISH_ERROR", - err, - event, - }); - }); - - req?.log.info({ - tag: "EVENT_PUBLISH", - event, - payload, - processAfterDelayMs, - }); - } - - const bus: EventBus = { - publish(event, payload, processAfterDelayMs) { - publishToServiceBus(event, payload, null, processAfterDelayMs ?? 0); - }, - }; - f.decorate("EventBus", { - getter() { - return bus; - }, - }); - - f.decorateRequest("EventBus", { - getter() { - return { - publish: (event, payload, processAfterDelayMs) => { - publishToServiceBus( - event, - payload, - null, - processAfterDelayMs ?? 0, - this, - ); - }, - }; - }, - }); - - const selectAndRunHandlers = CreateHandlerRunner(f, options, handlerMap); - - f.post<{ Body: IncomingServiceBusMessage }>( - "/azure-servicebus/process-message", - { - schema: { - hide: true, - } as any, - }, - async function (req, reply) { - const rawMsg = req.body; - if (!rawMsg) { - reply.send("OK"); - return reply; - } - req.log.info({ - tag: "AZURE_SERVICEBUS_MESSAGE", - messageId: rawMsg.messageId, - scheduledEnqueueTimeUtc: rawMsg.scheduledEnqueueTimeUtc, - }); - const msg = convert(rawMsg); - options.validateMsg(msg.event, msg.data, req); - - if (noMatchingHandlers(handlerMap, msg)) { - reply.send("OK"); - return reply; - } - - req.log.info({ - tag: "AZURE_SERVICEBUS_MESSAGE_HANDLE", - event: msg, - }); - - if ( - msg.processAfterDelayMs > 0 && - Date.now() < msg.publishTime.getTime() + msg.processAfterDelayMs - ) { - reply - .status(425) - .send({ processAfterDelayMs: msg?.processAfterDelayMs }); - return reply; - } - - try { - await selectAndRunHandlers(req, msg, (event, payload, file) => - publishToServiceBus( - event, - payload, - file, - msg.processAfterDelayMs, - req, - ), - ); - reply.send("OK"); - return reply; - } catch (err) { - if (err instanceof ErrorWithStatus) { - reply.status(err.status).send(err.message); - } else { - reply.status(500).send("ERROR"); - } - return reply; - } - }, - ); -}; - -export = fp(plugin, { name: "fp-eventbus-azure-servicebus" }); - -function convert(msg: IncomingServiceBusMessage): EventMessage { - const body: MessageBody = JSON.parse(msg.body); - return { - id: "" + msg.messageId, - attributes: { - event: body.event, - processAfterDelayMs: "" + (body.processAfterDelayMs ?? 0), - file: body.file ?? "", - }, - data: body.payload, - event: body.event, - processAfterDelayMs: body.processAfterDelayMs ?? 0, - publishTime: new Date(body.publishTimestamp), - }; -} -``` - -- [ ] **Step 2: Run tests** - -Run: `npx jest --testPathIgnorePatterns=rabbitmq.spec.ts -v` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add src/event-bus/azure-servicebus.ts -git commit -m "refactor(event-bus): remove Azure ServiceBus flush loop, publish directly - -Same fix as RabbitMQ — eliminates 20ms buffered flush. Adds init -failure guard (close client if sender creation fails) and safeAll -for shutdown cleanup." -``` - ---- - -### Task 5: Fix RabbitMQ consumer — connection error handler + safeAll shutdown - -**Files:** - -- Modify: `src/event-bus/event-consumer/rabbitmq.ts` - -Two fixes: - -1. The consumer creates its own `Connection` but never attaches an error handler. An `ECONNRESET` during broker restart will crash the process. -2. The `close()` method does sequential awaits without error protection — if `sub.close()` throws, `dlqPublisher` and `connection` are leaked. - -- [ ] **Step 1: Add connection error handler** - -In `src/event-bus/event-consumer/rabbitmq.ts`, after line 54 (`const connection = new Connection(process.env.RABBITMQ_URL);`), add: - -```ts -connection.on("error", (err) => { - instance.log.error({ tag: "RABBITMQ_CONSUMER_CONNECTION_ERROR", err }); -}); -``` - -- [ ] **Step 2: Add `safeAll` import and update `close()` method** - -Add import at top of file: - -```ts -import { safeAll } from "../utils"; -``` - -Replace the close method (around lines 184-189): - -```ts -// Old: - close: async () => { - ctrl.abort(); - await sub.close(); - await dlqPublisher.close(); - await connection.close(); - }, - -// New: - close: async () => { - ctrl.abort(); - const errors = await safeAll( - () => sub.close(), - () => dlqPublisher.close(), - () => connection.close(), - ); - if (errors.length > 0) { - instance.log.error({ tag: "RABBITMQ_CONSUMER_CLOSE_ERRORS", errors }); - } - }, -``` - -- [ ] **Step 3: Run tests** - -Run: `npx jest rabbitmq.spec.ts --testTimeout=120000 -v` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add src/event-bus/event-consumer/rabbitmq.ts -git commit -m "fix(event-bus): add RabbitMQ consumer connection error handler + safeAll shutdown - -- Prevents unhandled ECONNRESET on broker restart from crashing the process -- Uses safeAll in close() so dlqPublisher and connection are always cleaned up" -``` - ---- - -### Task 6: Fix Azure ServiceBus consumer — shutdown safety, error tags, init guard - -**Files:** - -- Modify: `src/event-bus/event-consumer/azure-servicebus.ts` - -Three fixes: - -1. Don't call `receiver.abandonMessage(msg)` during shutdown — receiver may already be closing -2. Wrap `abandonMessage` calls in try/catch — they can throw if the lock expired -3. Distinguish `MSG_PROCESS_ERROR` from `TRANSPORT_ERROR` -4. Guard init: if `createReceiver` throws, close the `client` -5. Use `safeAll` for shutdown cleanup -6. Pass abort signal to `exponentialDelay` and `randomDelay` - -- [ ] **Step 1: Rewrite the consumer** - -```ts -import * as AzureIden from "@azure/identity"; -import { RetryMode, ServiceBusClient } from "@azure/service-bus"; -import { safeAll } from "../utils"; -import { EventConsumerBuilder } from "./interface"; -import { exponentialDelay, randomDelay } from "./utils"; - -export const AzureServiceBusConsumerBuilder: EventConsumerBuilder = async ( - instance, -) => { - if (!process.env.EVENT_NAMESPACE) { - throw new Error("Azure ServiceBus needs EVENT_NAMESPACE"); - } - if (!process.env.EVENT_TOPIC) { - throw new Error("Azure ServiceBus needs EVENT_TOPIC"); - } - if (!process.env.EVENT_SUBSCRIPTION) { - throw new Error("Azure ServiceBus needs EVENT_SUBSCRIPTION"); - } - - // All validation passed — safe to create resources - const client = new ServiceBusClient( - process.env.EVENT_NAMESPACE, - new AzureIden.DefaultAzureCredential({}), - { - retryOptions: { - mode: RetryMode.Fixed, - retryDelayInMs: 10_000, - timeoutInMs: 5_000, - maxRetries: 5, - }, - }, - ); - - let receiver; - try { - receiver = client.createReceiver( - process.env.EVENT_TOPIC, - process.env.EVENT_SUBSCRIPTION, - { - skipParsingBodyAsJson: true, - skipConvertingDate: true, - receiveMode: "peekLock", - }, - ); - } catch (err) { - await client.close(); - throw err; - } - - const ctrl = new AbortController(); - - const handler = receiver.subscribe( - { - async processMessage(msg) { - if (ctrl.signal.aborted) { - // Don't attempt abandonMessage during shutdown — receiver may already - // be closing. The broker will redeliver unacked messages after the - // connection drops. - return; - } - if (msg.deliveryCount && msg.deliveryCount > 1) { - instance.log.warn({ - tag: "AZURE_SERVICE_BUS_RECEIVER_RETRY", - deliveryCount: msg.deliveryCount, - messageId: msg.messageId, - }); - await exponentialDelay( - Math.max(msg.deliveryCount - 2, 0), - undefined, - undefined, - undefined, - ctrl.signal, - ); - } - try { - const payload = { - messageId: msg.messageId, - body: msg.body, - scheduledEnqueueTimeUtc: msg.scheduledEnqueueTimeUtc?.toISOString(), - }; - if (Buffer.isBuffer(payload.body)) { - payload.body = payload.body.toString("utf8"); - } - const resp = await instance.inject({ - method: "POST", - url: "/azure-servicebus/process-message", - payload, - }); - if (resp.statusCode >= 200 && resp.statusCode < 300) { - await receiver.completeMessage(msg); - } else if (resp.statusCode === 429 || resp.statusCode === 409) { - await randomDelay(undefined, ctrl.signal); - await receiver.abandonMessage(msg).catch((abandonErr) => { - instance.log.warn({ - tag: "AZURE_SERVICE_BUS_ABANDON_FAILED", - err: abandonErr, - messageId: msg.messageId, - }); - }); - } else if (resp.statusCode === 425) { - instance.log.error({ - tag: "AZURE_SERVICE_BUS_DELAYED_MESSAGE", - payload, - }); - await receiver.abandonMessage(msg).catch((abandonErr) => { - instance.log.warn({ - tag: "AZURE_SERVICE_BUS_ABANDON_FAILED", - err: abandonErr, - messageId: msg.messageId, - }); - }); - } else { - await receiver.abandonMessage(msg).catch((abandonErr) => { - instance.log.warn({ - tag: "AZURE_SERVICE_BUS_ABANDON_FAILED", - err: abandonErr, - messageId: msg.messageId, - }); - }); - } - } catch (err) { - if ((err as Error).name !== "AbortError") { - instance.log.error({ - tag: "AZURE_SERVICE_BUS_MSG_PROCESS_ERROR", - err, - }); - } - // Skip abandonMessage during shutdown - if (!ctrl.signal.aborted) { - await receiver.abandonMessage(msg).catch((abandonErr) => { - instance.log.warn({ - tag: "AZURE_SERVICE_BUS_ABANDON_FAILED", - err: abandonErr, - messageId: msg.messageId, - }); - }); - } - } - }, - async processError(args) { - instance.log.error({ - tag: "AZURE_SERVICE_BUS_TRANSPORT_ERROR", - err: args.error, - entityPath: args.entityPath, - }); - }, - }, - { - abortSignal: ctrl.signal, - autoCompleteMessages: false, - maxConcurrentCalls: - parseInt( - process.env.EVENT_SUBSCRIPTION_MAX_CONCURRENT_CALLS ?? "10", - 10, - ) || 10, - }, - ); - instance.log.info( - "Attached to Azure ServiceBus Subscription=" + - process.env.EVENT_SUBSCRIPTION, - ); - return { - close: async () => { - // Abort BEFORE closing: prevents shutdown from blocking on broker - // round-trips. In-flight messages get redelivered by the broker. - ctrl.abort(); - const errors = await safeAll( - () => handler.close(), - () => receiver.close(), - () => client.close(), - ); - if (errors.length > 0) { - instance.log.error({ tag: "AZURE_SERVICE_BUS_CLOSE_ERRORS", errors }); - } - }, - }; -}; -``` - -- [ ] **Step 2: Run tests** - -Run: `npx jest --testPathIgnorePatterns=rabbitmq.spec.ts -v` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add src/event-bus/event-consumer/azure-servicebus.ts -git commit -m "fix(event-bus): fix Azure ServiceBus consumer shutdown and error handling - -- Don't abandonMessage during shutdown (receiver may already be closing) -- Wrap abandonMessage calls in .catch() (lock may have expired) -- Distinct error tags: MSG_PROCESS_ERROR vs TRANSPORT_ERROR -- Guard init: close client if createReceiver throws -- Use safeAll for shutdown cleanup -- Pass abort signal to delay functions" -``` - ---- - -### Task 7: Fix GCP Pub/Sub consumer — abort-safe reconnect, abort-aware delays - -**Files:** - -- Modify: `src/event-bus/event-consumer/gcp-pubsub.ts` - -Three fixes: - -1. The reconnect timer's `setTimeout` callback should check abort signal before calling `init()` to prevent a race with `close()` -2. The `randomDelay()` call on 429/409 responses should pass the abort signal (otherwise blocks shutdown up to 10s) -3. The `timers.setTimeout(processAfterDelayMs)` for delayed messages should be abort-aware -4. Use `safeAll` for close - -- [ ] **Step 1: Add abort check before reconnect init** - -In `src/event-bus/event-consumer/gcp-pubsub.ts`, in the `init()` method's error handler (around line 90-93), the reconnect `setTimeout` callback should check `ctrl.signal.aborted` before calling `this.init()`: - -Replace: - -```ts -this.timerRef = setTimeout(() => { - this.init(); -}, 10000); -``` - -With: - -```ts -this.timerRef = setTimeout(() => { - if (!this.ctrl.signal.aborted) { - this.init(); - } -}, 10000); -``` - -- [ ] **Step 2: Pass abort signal to delay calls in `processMsg`** - -In the `processMsg` method, update the `randomDelay` call (around line 124) and the delayed message sleep (around line 130): - -Replace: - -```ts - } else if (resp.statusCode === 429 || resp.statusCode === 409) { - // rate-limited or lock-conflict - await randomDelay(); - msg.nack(); - } else if (resp.statusCode === 425 && attempt < 2) { - const parsed = JSON.parse(resp.body); - const processAfterDelayMs = parsed?.processAfterDelayMs ?? 0; - if (processAfterDelayMs > 0) { - await timers.setTimeout(processAfterDelayMs); - } -``` - -With: - -```ts - } else if (resp.statusCode === 429 || resp.statusCode === 409) { - // rate-limited or lock-conflict - await randomDelay(undefined, this.ctrl.signal); - msg.nack(); - } else if (resp.statusCode === 425 && attempt < 2) { - const parsed = JSON.parse(resp.body); - const processAfterDelayMs = parsed?.processAfterDelayMs ?? 0; - if (processAfterDelayMs > 0) { - try { - await timers.setTimeout(processAfterDelayMs, undefined, { signal: this.ctrl.signal }); - } catch (err) { - if ((err as Error).name === "AbortError") { - msg.nack(); - return; - } - throw err; - } - } -``` - -- [ ] **Step 3: Use `safeAll` for close** - -Add import at the top: - -```ts -import { safeAll } from "../utils"; -``` - -In the factory function's returned `close` method, use `safeAll`: - -Replace: - -```ts -return { - close: async () => { - await runner.close(); - await pubsub.close(); - }, -}; -``` - -With: - -```ts -return { - close: async () => { - const errors = await safeAll( - () => runner.close(), - () => pubsub.close(), - ); - if (errors.length > 0) { - instance.log.error({ tag: "GCP_PUBSUB_CLOSE_ERRORS", errors }); - } - }, -}; -``` - -- [ ] **Step 4: Run tests** - -Run: `npx jest --testPathIgnorePatterns=rabbitmq.spec.ts -v` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/event-bus/event-consumer/gcp-pubsub.ts -git commit -m "fix(event-bus): abort-safe reconnect and delays for GCP Pub/Sub consumer - -- Abort check before reconnect init to prevent race with close() -- Pass abort signal to randomDelay and delayed message sleep -- Use safeAll for shutdown cleanup" -``` - ---- - -### Task 8: Update RabbitMQ integration tests - -**Files:** - -- Modify: `src/event-bus/rabbitmq.spec.ts` - -The existing test `"should publish messages to RabbitMQ"` waits 100ms for the flush interval. With direct publish, this still works (the `setTimeout(100)` just adds safety margin), but we should verify the test passes reliably and potentially reduce the wait time. - -- [ ] **Step 1: Run full integration test suite** - -Run: `npx jest rabbitmq.spec.ts --testTimeout=120000 -v` -Expected: PASS — all tests green. The 100ms sleep in `"should publish messages to RabbitMQ"` is still fine as publisher.send() is async fire-and-forget. - -- [ ] **Step 2: Run all tests together** - -Run: `npx jest --testTimeout=120000 -v` -Expected: PASS — all tests pass, including unit tests and integration tests - -- [ ] **Step 3: Commit (if any test adjustments were needed)** - -```bash -# Only if tests needed modification -git add src/event-bus/rabbitmq.spec.ts -git commit -m "test(event-bus): update integration tests for direct publish" -``` - ---- - -### Task 9: Verify build and final checks - -**Files:** None — verification only - -- [ ] **Step 1: Run build** - -Run: `pnpm run build` -Expected: Clean build, no TypeScript errors - -- [ ] **Step 2: Run all tests** - -Run: `pnpm test` -Expected: PASS - -- [ ] **Step 3: Run RabbitMQ integration tests** - -Run: `npx jest rabbitmq.spec.ts --testTimeout=120000 -v` -Expected: PASS - -- [ ] **Step 4: Check that `mnemonist` is no longer imported in modified files** - -Run: `grep -r "mnemonist" src/event-bus/rabbitmq.ts src/event-bus/azure-servicebus.ts` -Expected: No matches. `mnemonist` is still a dependency (used elsewhere or kept for compatibility), but no longer imported in these two files. - -- [ ] **Step 5: Verify no leftover dead code** - -Run: `grep -rn "createMessageFlusher\|flushBatch\|MessageWithAttempts\|msgQueue" src/event-bus/` -Expected: No matches in `rabbitmq.ts` or `azure-servicebus.ts`. May still appear in test files if they reference old behavior — update if found. - ---- - -## Summary of Changes - -| File | Lines Removed (approx) | Lines Added (approx) | What Changed | -| -------------------------------------------------- | ---------------------- | -------------------- | ----------------------------------------------------------------------------- | -| `src/event-bus/utils.ts` | 0 | 15 | New `safeAll` utility | -| `src/event-bus/utils.spec.ts` | 0 | 40 | Tests for `safeAll` | -| `src/event-bus/rabbitmq.ts` | ~150 | ~100 | Remove flush loop, direct publish, connection error handler, safeAll shutdown | -| `src/event-bus/azure-servicebus.ts` | ~100 | ~70 | Remove flush loop, direct publish, init guard, safeAll shutdown | -| `src/event-bus/event-consumer/utils.ts` | 0 | ~10 | Abort signal support on delay functions | -| `src/event-bus/event-consumer/rabbitmq.ts` | ~5 | ~15 | Connection error handler, safeAll shutdown | -| `src/event-bus/event-consumer/azure-servicebus.ts` | ~30 | ~60 | Shutdown safety, error tags, init guard, safeAll | -| `src/event-bus/event-consumer/gcp-pubsub.ts` | ~5 | ~20 | Abort-safe reconnect, abort-aware delays, safeAll close | - -**Net effect:** ~280 lines removed, ~310 lines added (most additions are in the new utility + tests + more robust error handling replacing the flush machinery). diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0eff9ac --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,92 @@ +// @ts-check +const js = require("@eslint/js"); +const tseslint = require("typescript-eslint"); +const n = require("eslint-plugin-n"); + +module.exports = tseslint.config( + { + ignores: [ + "dist/**", + "coverage/**", + "node_modules/**", + // Standalone example app, not part of the tsconfig program. + "sample/**", + ], + }, + + // Root config files are plain CommonJS and outside tsconfig's "src" include, so the + // type-aware rules have no program for them. + { + files: ["*.js"], + ...js.configs.recommended, + languageOptions: { + sourceType: "commonjs", + globals: { + module: "writable", + require: "readonly", + __dirname: "readonly", + }, + }, + }, + + // Everything below is type-aware and scoped to the sources. + { + files: ["src/**/*.ts"], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: __dirname }, + }, + plugins: { n }, + rules: { + // Unhandled rejections in a storage client surface as silent data loss. + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": "error", + "n/no-process-exit": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, + + { + // The provider SDKs surface errors as `any`, and the lazily require()d holders are + // deliberately untyped at the call site. Narrowing those is a separate change. + files: ["src/file-store.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-require-imports": "off", + // Configure* and the plugin entry are async because Fastify/avvio requires a + // promise-returning plugin signature, and the FileStore methods must match the + // interface's Promise return type even when the body has nothing to await. + "@typescript-eslint/require-await": "off", + }, + }, + + { + // Mocks are `any` by nature; the type-safety rules fight the test doubles rather + // than catching anything real. + files: ["src/**/*.spec.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + // Specs use require() to re-import modules under jest.isolateModules. + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/require-await": "off", + }, + }, +); diff --git a/jest.config.js b/jest.config.js index 3d71028..fd8ea1b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,21 +2,22 @@ module.exports = { roots: ["/src"], preset: "ts-jest", testEnvironment: "node", - bail: 1, modulePaths: ["/src"], testPathIgnorePatterns: ["/node_modules"], transform: { "^.+\\.tsx?$": [ "ts-jest", { - diagnostics: false, isolatedModules: false, - include: [], + // 151002 is ts-jest warning that hybrid module kinds want isolatedModules. + // Turning that on zeroes function coverage for index.ts, so the warning is + // silenced instead — type checking itself stays on. + diagnostics: { ignoreCodes: [151002] }, // tsconfig.json sets sourceMap:false, which leaves istanbul reporting emitted-JS // line numbers — TS parameter properties expand on compile, so the coverage // report pointed at imports and comments. Overridden here only; the published // build still comes from tsconfig.build.json without source maps. - tsconfig: { sourceMap: true }, + tsconfig: { sourceMap: true, types: ["jest", "node"] }, }, ], }, @@ -24,4 +25,9 @@ module.exports = { coverageDirectory: "/coverage", coverageReporters: ["text", "lcov"], coveragePathIgnorePatterns: ["/node_modules/"], + // The suite covers every branch today. Without a floor, coverage is reported and + // then ignored, so a regression lands green. + coverageThreshold: { + global: { statements: 100, branches: 100, functions: 100, lines: 100 }, + }, }; diff --git a/package.json b/package.json index 48e42e9..b2d3cf6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "test:coverage": "jest --coverage", "transpile": "tsc -p tsconfig.build.json", "build": "run-s clean transpile", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:integration": "MINIO_TEST_ENDPOINT=${MINIO_TEST_ENDPOINT:-http://127.0.0.1:19000} jest src/file-store.minio.integration.spec.ts --coverage=false" }, "author": "Stackbox ", "license": "MIT", @@ -36,16 +39,19 @@ "@aws-sdk/lib-storage": "^3.1045.0", "@azure/identity": "^4.13.1", "@azure/storage-blob": "^12.31.0", - "@google-cloud/storage": "^7.19.0", + "@google-cloud/storage": "^8.0.0", "fastify-plugin": "^6.0.0" }, "peerDependencies": { "fastify": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/jest": "^30.0.0", "@types/node": "^26.2.0", + "eslint": "^10.8.1", + "eslint-plugin-n": "^18.3.0", "husky": "^9.1.7", "jest": "^30.4.2", "lint-staged": "^17.0.4", @@ -54,7 +60,8 @@ "rimraf": "^6.1.3", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0" }, "packageManager": "pnpm@10.13.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6af3b7..e4803a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^12.31.0 version: 12.33.0 '@google-cloud/storage': - specifier: ^7.19.0 - version: 7.22.0 + specifier: ^8.0.0 + version: 8.0.0 fastify: specifier: ^3.0.0 || ^4.0.0 || ^5.0.0 version: 5.12.0 @@ -33,6 +33,9 @@ importers: specifier: ^6.0.0 version: 6.0.0 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.8.1) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.9.6) @@ -42,6 +45,12 @@ importers: '@types/node': specifier: ^26.2.0 version: 26.2.0 + eslint: + specifier: ^10.8.1 + version: 10.8.1 + eslint-plugin-n: + specifier: ^18.3.0 + version: 18.3.0(eslint@10.8.1)(typescript@6.0.3) husky: specifier: ^9.1.7 version: 9.1.7 @@ -69,6 +78,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + typescript-eslint: + specifier: ^8.67.0 + version: 8.67.0(eslint@10.8.1)(typescript@6.0.3) packages: @@ -399,6 +411,45 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} @@ -429,9 +480,29 @@ packages: resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} engines: {node: '>=14'} - '@google-cloud/storage@7.22.0': - resolution: {integrity: sha512-W98gTQOAntEeEQ7/pZxSQXxfUO5CiQcXJRsxBpUa6UU25n8YN/VtogPBi0H+MAmUrn/6bY/sBhFansoWEsr2/g==} - engines: {node: '>=18'} + '@google-cloud/storage@8.0.0': + resolution: {integrity: sha512-hSxyCimnjmPSinVHOQv+TUzIdaH4cei6cnHmnXnEs6UuI25jcdVuVcEAFYU3/crBivOkd2Fe2WBBwDKcoS6VVA==} + engines: {node: '>=22'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -653,6 +724,12 @@ packages: '@types/caseless@0.12.5': resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -665,6 +742,9 @@ packages: '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} @@ -683,6 +763,65 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typespec/ts-http-runtime@0.3.8': resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} engines: {node: '>=22.0.0'} @@ -807,6 +946,11 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -832,6 +976,9 @@ packages: ajv: optional: true + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -1064,6 +1211,9 @@ packages: babel-plugin-macros: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -1125,6 +1275,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1152,11 +1306,82 @@ packages: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-n@18.3.0: + resolution: {integrity: sha512-cPVguuDe6DrIPb/qUXHf8P89MaVTUmiYWwpt5gX5AILsvRIiZAxMFXcFR6QHYBksqKJpjfUBlL/RleCJUWcD7w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=8.57.1' + ts-declaration-location: ^1.0.6 + typescript: '>=5.0.0' + peerDependenciesMeta: + ts-declaration-location: + optional: true + typescript: + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.8.1: + resolution: {integrity: sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -1192,6 +1417,9 @@ packages: fast-json-stringify@7.0.1: resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} @@ -1220,6 +1448,19 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + find-my-way@9.7.0: resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} @@ -1228,6 +1469,17 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1279,6 +1531,13 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-tsconfig@4.14.2: + resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1292,6 +1551,13 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} @@ -1366,6 +1632,14 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -1394,6 +1668,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1402,6 +1680,10 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -1594,6 +1876,9 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -1604,9 +1889,15 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -1622,10 +1913,17 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -1641,6 +1939,10 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} @@ -1796,6 +2098,10 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -1808,6 +2114,10 @@ packages: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -1883,6 +2193,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -1898,6 +2212,10 @@ packages: process-warning@5.1.0: resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} @@ -1941,6 +2259,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -2105,6 +2426,10 @@ packages: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + teeny-request@9.0.0: resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} engines: {node: '>=14'} @@ -2121,6 +2446,10 @@ packages: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -2131,6 +2460,12 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-jest@29.4.12: resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} @@ -2175,6 +2510,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -2187,6 +2526,13 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -2209,6 +2555,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2243,6 +2592,10 @@ packages: engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -2823,6 +3176,40 @@ snapshots: tslib: 2.8.1 optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.1)': + dependencies: + eslint: 10.8.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.8.1)': + optionalDependencies: + eslint: 10.8.1 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@fastify/ajv-compiler@4.0.6': dependencies: ajv: 8.20.0 @@ -2855,7 +3242,7 @@ snapshots: '@google-cloud/promisify@4.0.0': {} - '@google-cloud/storage@7.22.0': + '@google-cloud/storage@8.0.0': dependencies: '@google-cloud/paginator': 5.0.2 '@google-cloud/projectify': 4.0.0 @@ -2875,6 +3262,22 @@ snapshots: - encoding - supports-color + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3207,6 +3610,10 @@ snapshots: '@types/caseless@0.12.5': {} + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -3222,6 +3629,8 @@ snapshots: expect: 30.4.1 pretty-format: 30.4.1 + '@types/json-schema@7.0.15': {} + '@types/node@26.2.0': dependencies: undici-types: 8.3.0 @@ -3243,6 +3652,97 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1)(typescript@6.0.3))(eslint@10.8.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 10.8.1 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@10.8.1)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + eslint: 10.8.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@10.8.1)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.8.1 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@10.8.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) + eslint: 10.8.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + '@typespec/ts-http-runtime@0.3.8': dependencies: http-proxy-agent: 7.0.2 @@ -3329,6 +3829,10 @@ snapshots: abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn-walk@8.3.5: dependencies: acorn: 8.18.0 @@ -3347,6 +3851,13 @@ snapshots: optionalDependencies: ajv: 8.20.0 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -3570,6 +4081,8 @@ snapshots: dedent@1.7.2: {} + deep-is@0.1.4: {} + deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -3620,6 +4133,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -3643,8 +4161,100 @@ snapshots: escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.8.1): + dependencies: + eslint: 10.8.1 + semver: 7.8.5 + + eslint-plugin-es-x@7.8.0(eslint@10.8.1): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.8.1 + eslint-compat-utils: 0.5.1(eslint@10.8.1) + + eslint-plugin-n@18.3.0(eslint@10.8.1)(typescript@6.0.3): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) + enhanced-resolve: 5.24.5 + eslint: 10.8.1 + eslint-plugin-es-x: 7.8.0(eslint@10.8.1) + get-tsconfig: 4.14.2 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.5 + optionalDependencies: + typescript: 6.0.3 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.8.1: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + event-target-shim@5.0.1: {} events@3.3.0: {} @@ -3689,6 +4299,8 @@ snapshots: json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 @@ -3739,6 +4351,14 @@ snapshots: dependencies: bser: 2.1.1 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 @@ -3750,6 +4370,18 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -3817,6 +4449,14 @@ snapshots: get-stream@6.0.1: {} + get-tsconfig@4.14.2: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -3841,6 +4481,10 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + globals@15.15.0: {} + + globrex@0.1.2: {} + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 @@ -3927,6 +4571,10 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.6: {} + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -3947,10 +4595,16 @@ snapshots: is-docker@3.0.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -4331,6 +4985,8 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@6.0.0: {} @@ -4339,8 +4995,12 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} jsonwebtoken@9.0.3: @@ -4367,8 +5027,17 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + leven@3.1.0: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -4389,6 +5058,10 @@ snapshots: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash-es@4.18.1: {} lodash.includes@4.3.0: {} @@ -4509,6 +5182,15 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -4521,6 +5203,10 @@ snapshots: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -4590,6 +5276,8 @@ snapshots: dependencies: find-up: 4.1.0 + prelude-ls@1.2.1: {} + prettier@3.9.6: {} pretty-format@30.4.1: @@ -4603,6 +5291,8 @@ snapshots: process-warning@5.1.0: {} + punycode@2.3.1: {} + pure-rand@7.0.1: {} quick-format-unescaped@4.0.4: {} @@ -4636,6 +5326,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + ret@0.5.0: {} retry-request@7.0.2: @@ -4775,6 +5467,8 @@ snapshots: dependencies: '@pkgr/core': 0.3.6 + tapable@2.3.3: {} + teeny-request@9.0.0: dependencies: http-proxy-agent: 5.0.0 @@ -4798,12 +5492,21 @@ snapshots: tinyexec@1.3.0: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tmpl@1.0.5: {} toad-cache@3.7.4: {} tr46@0.0.3: {} + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@30.4.2(@types/node@26.2.0)(ts-node@10.9.2(@types/node@26.2.0)(typescript@6.0.3)))(typescript@6.0.3): dependencies: bs-logger: 0.2.6 @@ -4844,12 +5547,27 @@ snapshots: tslib@2.8.1: {} + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-detect@4.0.8: {} type-fest@0.21.3: {} type-fest@4.41.0: {} + typescript-eslint@8.67.0(eslint@10.8.1)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1)(typescript@6.0.3))(eslint@10.8.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1)(typescript@6.0.3) + eslint: 10.8.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + typescript@6.0.3: {} uglify-js@3.19.3: @@ -4890,6 +5608,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} uuid@9.0.1: {} @@ -4921,6 +5643,8 @@ snapshots: dependencies: isexe: 4.0.0 + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} wrap-ansi@7.0.0: diff --git a/src/file-store.azure.spec.ts b/src/file-store.azure.spec.ts index 1337bb7..873a79a 100644 --- a/src/file-store.azure.spec.ts +++ b/src/file-store.azure.spec.ts @@ -30,7 +30,7 @@ describe("AzureFileStore", () => { getBlockBlobClient: jest.fn(() => mockBlobClient), }; - (BlobServiceClient as jest.Mock).mockImplementation(() => ({ + (BlobServiceClient as unknown as jest.Mock).mockImplementation(() => ({ getContainerClient: jest.fn(() => mockContainerClient), })); diff --git a/src/file-store.local.spec.ts b/src/file-store.local.spec.ts index 0817ef6..cf03d90 100644 --- a/src/file-store.local.spec.ts +++ b/src/file-store.local.spec.ts @@ -167,9 +167,7 @@ describe("LocalFileStore", () => { it("throws File not found when stat yields nothing", async () => { const store = await register(); - jest - .spyOn(fs.promises, "stat") - .mockResolvedValue(undefined as unknown as fs.Stats); + jest.spyOn(fs.promises, "stat").mockResolvedValue(undefined); await expect(store.getAsBuffer("ghost.txt")).rejects.toThrow( `File not found: ${path.join(tempDir, "ghost.txt")}`, ); @@ -186,9 +184,7 @@ describe("LocalFileStore", () => { it("throws File not found when stat yields nothing", async () => { const store = await register(); - jest - .spyOn(fs.promises, "stat") - .mockResolvedValue(undefined as unknown as fs.Stats); + jest.spyOn(fs.promises, "stat").mockResolvedValue(undefined); await expect(store.getAsStream("ghost.txt")).rejects.toThrow( `File not found: ${path.join(tempDir, "ghost.txt")}`, ); @@ -251,7 +247,6 @@ describe("index barrel", () => { }); it("re-exports the plugin", () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires expect(require("./index").Plugins.FileStore).toBe(FileStorePlugin); }); diff --git a/src/file-store.minio.integration.spec.ts b/src/file-store.minio.integration.spec.ts new file mode 100644 index 0000000..bd05aa8 --- /dev/null +++ b/src/file-store.minio.integration.spec.ts @@ -0,0 +1,106 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Readable } from "node:stream"; +import * as S3 from "@aws-sdk/client-s3"; +import Fastify from "fastify"; +import FileStorePlugin, { FileStore } from "./file-store"; + +// The only test that speaks a real S3 wire protocol. Everything else mocks the SDK, +// which cannot catch a behavioural change in the client — the NoSuchKey/NotFound +// mismatch fixed in #9 was invisible to the mocked suite. +// +// Opt-in: set MINIO_TEST_ENDPOINT. Skipped otherwise so the default suite needs no +// Docker. See CONTRIBUTING.md for the one-liner that starts a container. +const endpoint = process.env.MINIO_TEST_ENDPOINT; +const describeIf = endpoint ? describe : describe.skip; + +describeIf("MinIO integration", () => { + let fastify: ReturnType; + let store: FileStore; + const ORIGINAL_ENV = process.env; + const bucket = "fp-plugins-it"; + + beforeAll(async () => { + const client = new S3.S3Client({ + region: "us-east-1", + endpoint, + credentials: { + accessKeyId: process.env.MINIO_TEST_ACCESS_KEY ?? "minioadmin", + secretAccessKey: process.env.MINIO_TEST_SECRET_KEY ?? "minioadmin", + }, + forcePathStyle: true, + }); + try { + await client.send(new S3.CreateBucketCommand({ Bucket: bucket })); + } catch { + // already exists + } + }, 60_000); + + beforeEach(async () => { + process.env = { ...ORIGINAL_ENV }; + process.env.MINIO_ENDPOINT = endpoint; + process.env.MINIO_ACCESS_KEY_ID = + process.env.MINIO_TEST_ACCESS_KEY ?? "minioadmin"; + process.env.MINIO_SECRET_ACCESS_KEY = + process.env.MINIO_TEST_SECRET_KEY ?? "minioadmin"; + process.env.MINIO_BUCKET = bucket; + fastify = Fastify({ logger: false }); + await fastify.register(FileStorePlugin, { type: "minio" }); + store = fastify.FileStore; + }); + + afterEach(async () => { + process.env = ORIGINAL_ENV; + await fastify.close(); + }); + + it("reports a missing key as false rather than throwing", async () => { + expect(await store.exists(`missing-${Date.now()}.txt`)).toBe(false); + }); + + it("returns null from getInfo for a missing key", async () => { + expect(await store.getInfo(`missing-${Date.now()}.txt`)).toBeNull(); + }); + + it("round-trips a string payload", async () => { + const key = `round-trip-${Date.now()}.txt`; + await store.save(key, "text/plain", "hello minio"); + expect(await store.exists(key)).toBe(true); + expect((await store.getAsBuffer(key)).toString()).toBe("hello minio"); + }); + + it("reports size and content type from a real HeadObject", async () => { + const key = `info-${Date.now()}.txt`; + await store.save(key, "text/plain", "12345"); + const info = await store.getInfo(key); + expect(info?.size).toBe(5); + expect(info?.contentType).toBe("text/plain"); + expect(info?.lastModified.getTime()).toBeGreaterThan(0); + }); + + it("streams a payload in and back out", async () => { + const key = `stream-${Date.now()}.txt`; + await store.copyFromStream( + key, + "text/plain", + Readable.from(["a", "b", "c"]), + ); + const rs = await store.getAsStream(key); + const chunks: Buffer[] = []; + for await (const c of rs as unknown as AsyncIterable) + chunks.push(c); + expect(Buffer.concat(chunks).toString()).toBe("abc"); + }); + + it("uploads a local file through the multipart path", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "fp-it-")); + const local = path.join(dir, "src.bin"); + await fs.promises.writeFile(local, Buffer.alloc(64 * 1024, 7)); + const key = `upload-${Date.now()}.bin`; + await store.copyFromLocalFile(key, "application/octet-stream", local); + expect((await store.getInfo(key))?.size).toBe(64 * 1024); + await fs.promises.rm(dir, { recursive: true }); + }, 30_000); +}); diff --git a/src/file-store.spec.ts b/src/file-store.spec.ts index 8c53d93..74f2915 100644 --- a/src/file-store.spec.ts +++ b/src/file-store.spec.ts @@ -46,7 +46,9 @@ describe("FileStore Plugin", () => { const mockS3Client = { send: jest.fn(), }; - (S3.S3Client as jest.Mock).mockImplementation(() => mockS3Client); + (S3.S3Client as unknown as jest.Mock).mockImplementation( + () => mockS3Client, + ); await fastify.register(FileStorePlugin, { type: "s3" }); expect(fastify.FileStore).toBeDefined(); @@ -166,7 +168,9 @@ describe("FileStore Plugin", () => { send: jest.fn(), }; - (S3.S3Client as jest.Mock).mockImplementation(() => mockS3Client); + (S3.S3Client as unknown as jest.Mock).mockImplementation( + () => mockS3Client, + ); process.env.AWS_REGION = "us-east-1"; process.env.S3_BUCKET = "test-bucket"; @@ -253,7 +257,7 @@ describe("FileStore Plugin", () => { getContainerClient: jest.fn(() => mockContainerClient), }; - (BlobServiceClient as jest.Mock).mockImplementation( + (BlobServiceClient as unknown as jest.Mock).mockImplementation( () => mockBlobServiceClient, ); diff --git a/src/index.ts b/src/index.ts index 8ed9fbf..a429d1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ import FpFileStore from "./file-store"; +// Side-effect import. Module augmentations only reach consumers if the declaring file +// is in their compilation; nothing referenced ./types before, so even the existing +// FastifySchema augmentation was never applied. +import "./types"; export { FileStore } from "./file-store"; diff --git a/src/types.ts b/src/types.ts index 73a45d3..8372f9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { FileStore } from "./file-store"; + export {}; declare module "fastify" { @@ -6,4 +8,10 @@ declare module "fastify" { summary?: string; description?: string; } + + // FileStore is decorated onto the instance by the plugin. Without this, consumers + // get no compile-time safety on fastify.FileStore and have to declare it themselves. + export interface FastifyInstance { + FileStore: FileStore; + } } diff --git a/src/utils.ts b/src/utils.ts index 3b5bd28..6468f0d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,7 @@ export interface DataStream { + // Must stay any[]: node's EventEmitter declares `on` with `...args: any[]`, and a + // narrower element type makes Readable structurally incompatible. + // eslint-disable-next-line @typescript-eslint/no-explicit-any on(eventName: string | symbol, listener: (...args: any[]) => void): this; } @@ -8,7 +11,8 @@ export function streamToBuffer(stream: DataStream): Promise { stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)), ); - stream.on("error", (err) => reject(err)); + // Typed as Error so the rejection carries a stack; node streams always emit one. + stream.on("error", (err: Error) => reject(err)); stream.on("end", () => resolve(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks)), );