diff --git a/.env.country.example b/.env.country.example new file mode 100644 index 00000000..0e7c2f10 --- /dev/null +++ b/.env.country.example @@ -0,0 +1,3 @@ +# Copy this file as .env.pe / .env.mx / .env.co +COUNTRY_CODE=pe +COUNTRY_CURRENCY=PEN diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d0fa98b9 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +NODE_ENV=development +PORT=3000 + +# Default values for Docker Compose network +DB_HOST=yape-postgres +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=p0stgre$$ +DB_NAME=yape + +KAFKA_BROKERS=yape-kafka:9092 +KAFKA_CLUSTER_ID=4L6g3nShT-eMCtK--X86sw +KAFKA_CLIENT_ID=payment-platform +KAFKA_GROUP_PREFIX=challenge +SUPPORTED_COUNTRIES=pe,mx + +RELAY_POLL_MS=1000 +MAX_CONSUMER_RETRIES=3 + +# Enables per-country topic namespace: pe.payments.*, mx.payments.* +COUNTRY_NAMESPACE_ENABLED=true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..78874008 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM deps AS build +COPY tsconfig.json tsconfig.build.json jest.config.ts ./ +COPY src ./src +COPY test ./test +RUN npm run build + +FROM node:20-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force +COPY --from=build /app/dist ./dist +EXPOSE 3000 +CMD ["node", "dist/apps/payment-api/main.js"] diff --git a/Dockerfile.visual-demo b/Dockerfile.visual-demo new file mode 100644 index 00000000..f2bca3ba --- /dev/null +++ b/Dockerfile.visual-demo @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-alpine +WORKDIR /app + +RUN apk add --no-cache docker-cli + +COPY test-visual ./test-visual + +ENV PORT=4000 +EXPOSE 4000 + +CMD ["node", "test-visual/server.js"] diff --git a/README.md b/README.md index 14377fae..ceb14deb 100644 --- a/README.md +++ b/README.md @@ -1,197 +1,370 @@ -# Yape Code Challenge 🚀 +# Challenge 1 - Payment settlement pipeline -Welcome. This challenge is designed for experienced engineers being considered for **tech lead and staff-level** roles. It tests your ability to reason about distributed systems, event-driven architecture, and platform design — not just your ability to ship working code. +## ¿Por qué elegí este challenge? -There are three challenges. **Pick one.** Go deep rather than broad. +Elegí el **Challenge 1 - Payment settlement pipeline** porque representa mejor los problemas que aparecen en un sistema fintech real: escrituras dobles, entrega confiable de eventos, redelivery, idempotencia, fallas parciales y consistencia eventual. ---- +No lo tomé como un ejercicio de endpoints CRUD. Lo tomé como un problema de garantías distribuidas: cómo crear un pago sin perder eventos, cómo procesarlo aunque Kafka reentregue mensajes, cómo responder un estado honesto mientras los consumidores terminan, y cómo aislar el impacto entre países cuando un servicio crítico falla o tiene alto tráfico. -## Table of Contents +También elegí este reto porque permite explicar trade-offs importantes de arquitectura. En pagos no basta con que el happy path funcione; el diseño debe seguir siendo correcto si el relay se cae, si un consumer procesa dos veces el mismo mensaje, si un país se degrada o si un mensaje termina siendo imposible de procesar. -- [What we're evaluating](#what-were-evaluating) -- [Challenge 1 — Payment settlement pipeline](#challenge-1--payment-settlement-pipeline) -- [Challenge 2 — Wallet transfer with distributed saga](#challenge-2--wallet-transfer-with-distributed-saga) -- [Challenge 3 — Shared platform library design](#challenge-3--shared-platform-library-design) -- [Tech stack](#tech-stack) -- [Submission](#submission) +## Resumen de la solución ---- +La solución implementa un pipeline de settlement basado en eventos con estos componentes: -## What we're evaluating +```text +Payment API -> PostgreSQL outbox -> Outbox Relay -> Kafka -> Fraud / Ledger -> Status Saga -> settled | failed | DLT +``` + +Los procesos principales son: + +```text +payment-api +outbox-relay +fraud-consumer +ledger-consumer +status-saga +``` + +La solución también incluye Docker Compose, scripts de bootstrap local, tests automatizados, namespace por país, configuración por país y un demo visual tipo Yape que permite ejecutar escenarios reales y ver eventos Kafka, evidencia en base de datos y disponibilidad por país. -We're not looking for a perfect system. We're looking for evidence that you think like a tech lead: +## Decisiones arquitectónicas -- You treat trade-offs as first-class decisions, not implementation details. -- You build for the engineer who reads your code six months from now, not for the PR reviewer today. -- You can identify the antipattern in a brief before someone points it out to you. -- You know what you deliberately left out — and why. +### 1. Transactional Outbox -Every challenge includes an **optional escalation**. It's genuinely optional; finishing the core well beats rushing to the escalation. +Decidí usar el patrón **Transactional Outbox** para evitar el problema clásico de dual write: ---- +```text +DB commit OK, pero Kafka publish falla +``` + +Cuando se crea un pago, el `PaymentService` guarda en la misma transacción local: -## Challenge 1 — Payment settlement pipeline +```text +payments +outbox_events +``` -### Premise +El broker no se llama dentro de esa transacción. Esto es importante porque llamar Kafka dentro de una transacción SQL no hace que Kafka participe de esa transacción; solo crea una falsa sensación de atomicidad. -You're building the payment processing backbone for a multi-country wallet. A payment initiated in one country may touch ledger entries, fraud scoring, and notification services that are fully independent. Your solution must remain correct under partial failures and message redelivery. +La alternativa rechazada fue publicar directamente a Kafka desde el API durante la creación del pago. Es más simple de programar, pero puede producir pérdida silenciosa de eventos si la base de datos confirma y el broker falla después. -### Architecture overview +### 2. Relay separado del API +El `outbox-relay` corre como proceso separado. Lee filas pendientes de `outbox_events`, publica a Kafka y marca la fila como publicada. +Esta separación permite que el ciclo de vida del relay no dependa del API HTTP. También hace más claro el modelo de fallas: + +```text +Si el relay muere antes de publicar, la fila queda pending y se reintenta. +Si el relay publica pero muere antes de marcar published, el evento puede publicarse otra vez. +Ese duplicado es aceptable porque los consumers son idempotentes. ``` -Payment API ──► Outbox table ──[relay]──► Kafka topic - (payment.created.v1) - │ - ┌────────────────────┼────────────────────┐ - ▼ ▼ ▼ - FraudConsumer LedgerConsumer NotifyConsumer - (risk scoring) (double-entry write) (push / email) - │ │ - └────────┬───────────┘ - ▼ - Status saga - (eventual consistency) - │ - ┌──────────┴──────────┐ - ▼ (on failure) ▼ (on success) - DLT topic payment.settled.v1 - (payment.failed.v1) + +Para concurrencia en el relay se usa locking pesimista con `FOR UPDATE SKIP LOCKED`, de forma que varias instancias puedan reclamar lotes sin procesar la misma fila al mismo tiempo. + +### 3. PostgreSQL como base de datos local + +Elegí PostgreSQL porque ofrece garantías ACID sólidas para la transacción local `payment + outbox`. Además soporta locks transaccionales como `FOR UPDATE SKIP LOCKED`, que encajan bien con un relay concurrente basado en polling. + +La alternativa de usar una base más simple o almacenamiento en memoria habría reducido la complejidad inicial, pero no demostraría bien las garantías transaccionales que el challenge busca evaluar. + +### 4. Kafka con topics por país + +Implementé namespace por país para los topics: + +```text +pe.payments.payment.created.v1 +mx.payments.payment.created.v1 +pe.payments.fraud.assessed.v1 +mx.payments.fraud.assessed.v1 +pe.payments.ledger.posted.v1 +mx.payments.ledger.posted.v1 ``` -### Required deliverables +Esto permite separar tráfico, lag, DLTs y operación por país. Si MX tiene alto tráfico o una falla operativa, PE no debe quedar bloqueado por compartir el mismo flujo crítico. -1. **Transactional outbox** — A `PaymentService` (NestJS) that writes a payment record and its outbox entry in a single local transaction. A separate relay process publishes to Kafka. The broker must never be called inside the database transaction. +La alternativa rechazada fue usar un único topic global con `countryCode` dentro del payload. Esa opción simplifica nombres de topics, pero mezcla lag y operación entre países, lo cual complica aislamiento y respuesta ante incidentes. -2. **Idempotent consumers** — At least two downstream consumers (`FraudConsumer`, `LedgerConsumer`) in separate NestJS modules. Reprocessing the same event twice must produce no observable side effect. +### 5. Estrategia híbrida multi-país -3. **DLT handler** — When a consumer exceeds its retry budget, emit a compensating event to a Dead Letter Topic rather than silently dropping the message. +No elegí que todo fuera global ni que todo fuera aislado por país. Elegí una estrategia híbrida: -4. **Status query endpoint** — A GET endpoint that reflects eventual consistency honestly. A payment may return `pending` after creation and only transition to `settled` or `failed` once both consumers have acknowledged. +```text +Globales: +payment-api +outbox-relay +fraud-consumer -### What a strong solution looks like +Aislados por país: +ledger-consumer +status-saga +``` -- The outbox relay is a distinct process boundary — not a `setInterval` in the same NestJS app. -- Idempotency keys live on the consumer side (keyed by `eventId`), not on the producer side. -- The status endpoint documents its consistency guarantees explicitly — either in code comments or in an API response envelope. -- The candidate can explain what happens if the relay crashes between writing the outbox entry and publishing to Kafka. +`fraud-consumer` se mantiene global porque en esta solución representa una evaluación compartida y de menor costo operativo. En cambio, `ledger-consumer` y `status-saga` están aislados por país porque son parte crítica del settlement y del cierre del estado del pago. -### What disqualifies a solution +La alternativa de aislar todo por país aumenta control operativo, pero también multiplica costo y complejidad desde el inicio. La alternativa de dejar todo global reduce costo, pero debilita el aislamiento ante picos o fallas localizadas. -- Calling `kafkaClient.emit()` directly inside a `@Transaction()` decorator. This is the most common mistake at this level and it produces silent data loss. +### 6. Consumer groups por país -### Optional escalation +Los consumers críticos usan grupos por país: -Add a per-country topic namespace (`pe.payments.payment.created.v1`, `mx.payments.payment.created.v1`) and document what that implies for consumer group strategy across countries. +```text +challenge.ledger-consumer.pe +challenge.ledger-consumer.mx +challenge.status-saga.pe +challenge.status-saga.mx +``` ---- +El consumer global usa un grupo global: -## Challenge 2 — Wallet transfer with distributed saga +```text +challenge.fraud-consumer.global +``` -### Premise +Esto permite que el offset y el lag de los procesos críticos estén separados por país. También deja el sistema preparado para escalar horizontalmente un país sin tocar otro. -Transferring funds between two wallets in different countries requires debiting one ledger and crediting another atomically — without a distributed transaction. You will implement a saga that is safe to replay from any step. +Por ejemplo, si MX recibe más tráfico, puedo escalar los workers de MX sin escalar PE: -### Required deliverables +```bash +docker compose -p yape-mx -f docker-compose.country.yml --env-file .env --env-file .env.mx --profile country up -d --scale ledger-consumer=3 --scale status-saga=2 +``` -1. **Transfer orchestrator** — Implement a `TransferOrchestrator` that drives the following steps in order: +No implementé autoscaling automático porque el reto apunta a un entorno local con Docker Compose. En producción agregaría HPA basado en consumer lag, retry rate, CPU y latencia por país. - ``` - DebitWallet → CreditWallet → SettleFX → EmitReceipt - ``` +### 7. Idempotencia del lado consumidor - You may use Temporal, a hand-rolled state machine, or pure Kafka choreography. You must justify the choice in writing. +La idempotencia vive del lado consumidor, no del productor. Cada consumer registra una marca en `processed_events` antes de generar efectos observables. -2. **Compensation on failure** — If `CreditWallet` fails after `DebitWallet` succeeds, the orchestrator must issue a `ReverseDebit` compensation event. Silent failure is not acceptable. +La clave lógica es: -3. **CQRS read model** — A `TransferReadModel` updated via projected events, not by reading the write-side database. The read model must be consistent enough to serve a GET within 500ms of the saga completing. +```text +consumerName + countryCode + eventId +``` + +Esto es más seguro que usar solo `eventId`, porque en un sistema multi-país se evita una colisión accidental entre eventos de distintos países. También es más correcto que deduplicar por `paymentId`, porque un mismo pago puede producir varios eventos distintos. + +La alternativa rechazada fue confiar solo en Kafka o en el productor para evitar duplicados. Kafka puede entregar más de una vez bajo ciertos escenarios; por eso cada consumer debe ser capaz de recibir el mismo evento nuevamente sin duplicar side effects. -4. **Concurrency safety** — If two transfers attempt to debit the same wallet simultaneously, the second must detect the conflict and fail fast. A negative balance is never acceptable. +### 8. Status Saga basada en eventos -### What a strong solution looks like +Implementé una `status-saga` que escucha: + +```text +fraud.assessed.v1 +ledger.posted.v1 +``` -- The candidate picks a clear position on choreography vs. orchestration and can articulate the trade-off: choreography reduces coupling but makes the overall saga state invisible; orchestration makes state explicit but introduces a coordinator as a single point of failure. -- The idempotency key is placed on the saga instance, not on individual commands, and the candidate can explain why. -- The read model answers the question: "how do I know the read model isn't serving stale data immediately after the saga closes?" — whether via versioned events, a subscription mechanism, or a documented staleness window. +La saga actualiza `payment_steps` y reconcilia el estado final del pago: -### What disqualifies a solution +```text +fraud succeeded + ledger succeeded -> payment.settled.v1 +fraud failed o ledger failed -> payment.failed.v1 +faltan ACKs -> payment sigue pending +``` -A single database transaction spanning two service databases. This is the antipattern the challenge is explicitly designed to surface. +Esto hace que el endpoint de status sea honesto con la consistencia eventual. Un pago recién creado puede devolver `pending` hasta que los consumidores confirmen. -### Optional escalation +La alternativa rechazada fue marcar el pago como `settled` inmediatamente después de publicarlo a Kafka. Eso sería incorrecto porque publicar un evento no significa que fraude y ledger ya hayan terminado. -Model the FX settlement step as an external API call with a timeout. Show how the saga handles a timeout that leaves the FX state ambiguous — neither confirmed nor rejected. +### 9. DLT por topic original ---- +Si un consumer agota su presupuesto de retries, el mensaje se envía a un Dead Letter Topic: -## Challenge 3 — Shared platform library design +```text +{original-topic}.dlt +``` -### Premise +Ejemplos: -Your platform team owns the internal libraries that all product squads import. You've been asked to design and ship `@yape/kafka-module` — a NestJS dynamic module that wraps Kafka producer and consumer setup, enforces topic naming conventions, wires DLT automatically, and exposes typed event contracts. You are the only author. Four squads will consume it within the quarter. +```text +pe.payments.payment.created.v1.dlt +mx.payments.ledger.posted.v1.dlt +``` -### Required deliverables +Esto evita perder mensajes silenciosamente. También conserva trazabilidad por país y por tipo de evento. -1. **Dynamic module API** — A `KafkaModule.forFeature({ topics, consumerGroup })` dynamic module. The module must register producers and consumers via NestJS dependency injection, not global singletons. +Separé conceptualmente DLT de `payment.failed.v1`. El DLT representa una falla técnica de procesamiento o un mensaje inválido. `payment.failed.v1` representa un resultado de negocio o de saga donde el pago sí llegó a un estado terminal fallido. -2. **`@KafkaEvent()` decorator** — A `@KafkaEvent(topicName)` decorator that binds a handler method to a Kafka consumer, analogous to how NestJS `@MessagePattern` works internally. +### 10. Configuración de país y moneda -3. **Automatic DLT wiring** — If a handler throws and exceeds `maxRetries`, the module routes the message to `{original-topic}.dlt` without any code change required in the consuming squad. +Cada país tiene su propio archivo de configuración: -4. **`EventContract` type** — A generic type that enforces schema shape at compile time. Squads must not be able to publish to a topic with a payload that doesn't match the declared contract. A type mismatch must be a TypeScript compile error, not a runtime exception. +```text +.env.pe -> COUNTRY_CODE=pe, COUNTRY_CURRENCY=PEN +.env.mx -> COUNTRY_CODE=mx, COUNTRY_CURRENCY=MXN +.env.co -> COUNTRY_CODE=co, COUNTRY_CURRENCY=COP +``` -5. **ADR (Architecture Decision Record)** — Written in MADR format, covering: - - Why NestJS dynamic modules over a plain exported class. - - How you handle schema evolution without breaking consumers who still reference an older version. - - What you would add with two more weeks. +Decidí guardar la moneda en `.env.` porque la moneda default es una propiedad operativa del proceso país. Si mañana se agrega Colombia, no debería tocarse el código central para saber que `co` usa `COP`. -### What a strong solution looks like +Los scripts `init-local.sh` y `add-new-country.sh` preguntan y validan la moneda usando formato ISO de tres letras. -- The module API feels native to NestJS. A squad importing it should not need to understand Kafka internals to publish an event. -- Schema evolution is addressed concretely: additive fields, topic versioning (`payment.created.v2`), or a schema registry — the candidate picks one and defends it, with trade-offs acknowledged. -- The ADR reads like it was written for a real team, not as a post-hoc justification. It documents the options that were rejected and why. +### 11. Docker Compose reproducible -### What a weak solution looks like +Incluí un entorno local con Docker Compose para levantar infraestructura y servicios sin depender de instalaciones locales de Node o npm. -- The module wraps Kafka imperatively and tells squads to call `producer.send()` directly. -- `EventContract` is a runtime validation only (e.g. a Zod schema), with no compile-time enforcement. -- The ADR is a bulleted list with no trade-off reasoning. +La estructura está separada en dos capas: -### Optional escalation +```text +docker-compose.yml -> core compartido +docker-compose.country.yml -> workers aislados por país +``` -Publish the module to a local [Verdaccio](https://verdaccio.org/) registry. Document your versioning and release strategy, including how you would communicate breaking changes to consuming squads. +Esto evita duplicar YAML por cada país. Para agregar un país nuevo se crea `.env.` y se levanta el mismo compose con otro project name: + +```bash +docker compose -p yape-co -f docker-compose.country.yml --env-file .env --env-file .env.co --profile country up -d --build +``` + +### 12. Visual demo para explicar el sistema + +Además de los tests, agregué un demo visual tipo Yape. No lo hice como reemplazo de pruebas, sino como herramienta de explicación. + +El demo permite ver al mismo tiempo: + +```text +pantalla de usuario +estado de servicios +línea de tiempo del pipeline +eventos Kafka +evidencia en Postgres +estado por país +``` + +También incluye escenarios como: + +```text +success +fraud rejected +ledger down + retry +timeout pending +DLT invalid payload +replay idempotente +país aislado +MX caído, PE disponible +``` ---- +El escenario `MX caído, PE disponible` apaga workers críticos de MX y ejecuta un pago PE. La intención es demostrar visualmente que una caída operativa en MX no afecta la disponibilidad del settlement de PE. -## Tech stack +## Alternativas rechazadas -The following is the expected stack. Deviations are acceptable if you document the reason. +### Publicar en Kafka dentro de la transacción SQL -| Layer | Expected | -|---|---| -| Runtime | Node.js 20+ | -| Framework | NestJS | -| Messaging | Kafka (local via Docker, or Confluent Cloud) | -| Database | Your choice — document why | -| Orchestration | Temporal, native Kafka, or a state machine — justify the choice | -| Language | TypeScript (strict mode) | -| Containers | Docker Compose for local environment | +La rechacé porque no garantiza atomicidad real entre PostgreSQL y Kafka. Si una parte falla, el sistema puede quedar inconsistente. ---- +### Usar una transacción distribuida o 2PC -## Submission +La rechacé porque aumenta complejidad y no es una solución práctica para este escenario local. El outbox ofrece una garantía suficiente y más operable: persistir primero, publicar después, y tolerar duplicados con idempotencia. -1. Fork this repository. -2. Create a branch named `challenge/{your-name}`. -3. Open a pull request against `main` in this repository. +### Usar Debezium o CDC desde el inicio -Your PR description must include: +CDC sería una buena evolución para reducir polling y mejorar throughput, pero para el challenge preferí una implementación explícita del relay. Es más fácil de revisar y deja clara la decisión de no llamar Kafka dentro del transaction boundary. -- Which challenge you chose and why. -- The key architectural decisions you made and the alternatives you rejected. -- What you would do differently with more time. -- Any known limitations or shortcuts taken. +### Usar Temporal -**There is no time limit stated intentionally.** A focused solution delivered in four hours tells us more than an exhaustive one delivered in two days. Prioritise depth of reasoning over breadth of features. +Temporal sería útil para sagas más largas, con timers, compensaciones complejas y pasos externos ambiguos. Para este challenge, una saga ligera basada en eventos era suficiente y mantiene menos moving parts. -If you have questions, open an issue on this repository. We respond to issues within one business day. +### Hacer todos los servicios por país + +Lo rechacé como primera versión porque multiplica despliegues y costo operativo. Preferí aislar los procesos críticos y mantener globales los componentes que no necesitan aislamiento fuerte en esta etapa. + +### Hacer todo global + +También lo rechacé porque debilita el aislamiento. Si un país genera lag o tiene una falla de ledger, no debería impactar el cierre de pagos de otro país. + +### Usar BIAN como eje principal + +No modelé la solución bajo BIAN porque el challenge no lo pedía. Preferí concentrarme en garantías distribuidas, idempotencia, DLT, outbox y operación multi-país. Si la organización usa BIAN como marco de gobierno, los módulos podrían mapearse posteriormente a dominios como Payments, Fraud, Ledger y Notifications. + +## Qué pasa ante fallas + +### Si Kafka falla cuando se crea el pago + +No se pierde el pago ni el evento, porque el API no llama Kafka. El pago y la fila outbox ya quedaron persistidos en PostgreSQL. El relay seguirá intentando publicar cuando Kafka vuelva. + +### Si el relay muere antes de publicar + +La fila queda pendiente en `outbox_events`. Al reiniciar el relay, la fila se toma nuevamente y se publica. + +### Si el relay publica y muere antes de marcar published + +El evento puede publicarse otra vez. Este duplicado es tolerado porque Fraud, Ledger y Status Saga son idempotentes por `consumerName + countryCode + eventId`. + +### Si un consumer recibe el mismo evento dos veces + +Consulta `processed_events`. Si ya existe la marca de procesamiento, no ejecuta de nuevo el side effect. + +### Si un consumer agota retries + +El mensaje se envía al DLT correspondiente. No se descarta silenciosamente. + +### Si MX cae o tiene alto tráfico + +Los workers críticos de PE siguen con su propio consumer group y sus propios topics. El diseño permite operar y escalar por país. + +## Qué haría diferente con más tiempo + +- Agregaría migraciones TypeORM versionadas en lugar de depender de sincronización automática de esquema en local. +- Agregaría Schema Registry o validación formal de contratos para eventos, con reglas de compatibilidad backward/forward. +- Agregaría OpenTelemetry para trazabilidad end-to-end desde `POST /payments` hasta `payment.settled.v1` o DLT. +- Agregaría métricas por país: consumer lag, retry rate, DLT rate, tiempo en pending y throughput por topic. +- Agregaría dashboards y alertas por país para detectar degradación localizada. +- Agregaría limpieza/retención de `processed_events` para controlar crecimiento de la tabla de idempotencia. +- Evaluaría CDC con Debezium para reemplazar polling del outbox si el volumen crece. +- Implementaría autoscaling en Kubernetes con HPA basado en Kafka lag por país. +- Separaría físicamente bases o esquemas por dominio si el sistema evolucionara hacia microservicios más independientes. +- Agregaría runbooks de replay de DLT y procedimientos seguros para reprocesamiento. + +## Limitaciones conocidas + +- Uso una sola base PostgreSQL para simplificar el entorno local del challenge. En producción, Ledger, Fraud y Payments podrían tener ownership de datos más separado. +- Los eventos downstream de algunos workers se publican directamente desde el consumer. Con más tiempo aplicaría outbox también para esos eventos si se requiere la misma garantía fuerte que en `payment.created.v1`. +- El entorno Kafka local usa una topología simple de desarrollo, no un cluster productivo multi-broker. +- El relay usa polling. Es correcto para el challenge, pero en producción evaluaría CDC o tuning de polling/backoff. +- El fraud scoring es intencionalmente simple. El foco del reto está en garantías distribuidas, no en un motor real de riesgo. +- No implementé autenticación/autorización porque no era el foco del challenge. +- No implementé autoscaling automático; dejé la arquitectura lista para escalar por país, pero las reglas productivas vivirían en Kubernetes/observabilidad. +- El visual demo monta Docker socket para controlar containers en laboratorio local. Es útil para demostrar fallas, pero no es un patrón recomendado para producción. + +## Cómo validé la solución + +Incluí pruebas automatizadas para los puntos críticos: + +```text +payment-service +idempotency-consumers +status-saga +DLT handler +``` + +También agregué scripts para operación local: + +```bash +./scripts/init-local.sh +./scripts/run-test.sh +./scripts/run-kafka-scenario.sh +./scripts/add-new-country.sh +./scripts/cleanup-docker.sh +``` + +Y un demo visual en: + +```text +http://localhost:4000 +``` + +El objetivo del demo visual es facilitar la explicación en entrevista: se puede ejecutar un pago, ver el evento en Kafka, ver las filas reales en Postgres y demostrar qué ocurre bajo fallas parciales. + +## Cierre + +La decisión principal fue priorizar garantías operativas sobre simplicidad superficial. El sistema acepta que Kafka puede redeliver, que los consumidores pueden fallar, que la consistencia es eventual y que un país puede degradarse sin bloquear a otro. + +La promesa de esta solución no es consistencia inmediata. La promesa es: + +```text +no perder eventos, +no duplicar efectos, +reflejar el estado honestamente, +y aislar fallas críticas por país. +``` diff --git a/docker-compose.country.yml b/docker-compose.country.yml new file mode 100644 index 00000000..74b2b484 --- /dev/null +++ b/docker-compose.country.yml @@ -0,0 +1,42 @@ +x-country-env: &country-env + NODE_ENV: ${NODE_ENV:-production} + DB_HOST: ${DB_HOST:-yape-postgres} + DB_PORT: ${DB_PORT:-5432} + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_NAME: ${DB_NAME:-yape} + KAFKA_BROKERS: ${KAFKA_BROKERS:-yape-kafka:9092} + KAFKA_GROUP_PREFIX: ${KAFKA_GROUP_PREFIX:-challenge} + SUPPORTED_COUNTRIES: ${SUPPORTED_COUNTRIES:-pe,mx} + MAX_CONSUMER_RETRIES: ${MAX_CONSUMER_RETRIES:-3} + COUNTRY_NAMESPACE_ENABLED: ${COUNTRY_NAMESPACE_ENABLED:-true} + CONSUMER_COUNTRY: ${COUNTRY_CODE:?COUNTRY_CODE is required} + COUNTRY_CURRENCY: ${COUNTRY_CURRENCY:?COUNTRY_CURRENCY is required} + +x-country-service: &country-service + build: + context: . + dockerfile: Dockerfile + networks: [yape-shared] + +services: + ledger-consumer: + <<: *country-service + profiles: ["country", "ledger"] + environment: + <<: *country-env + KAFKA_CLIENT_ID: ledger-consumer-${COUNTRY_CODE} + command: ["npm", "run", "start:ledger-consumer"] + + status-saga: + <<: *country-service + profiles: ["country", "saga"] + environment: + <<: *country-env + KAFKA_CLIENT_ID: status-saga-${COUNTRY_CODE} + command: ["npm", "run", "start:status-saga"] + +networks: + yape-shared: + external: true + name: yape-shared diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..f8a3d1c8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,173 @@ +x-app-env: &app-env + NODE_ENV: ${NODE_ENV:-production} + PORT: ${PORT:-3000} + DB_HOST: ${DB_HOST:-yape-postgres} + DB_PORT: ${DB_PORT:-5432} + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_NAME: ${DB_NAME:-yape} + KAFKA_BROKERS: ${KAFKA_BROKERS:-yape-kafka:9092} + KAFKA_GROUP_PREFIX: ${KAFKA_GROUP_PREFIX:-challenge} + SUPPORTED_COUNTRIES: ${SUPPORTED_COUNTRIES:-pe,mx} + RELAY_POLL_MS: ${RELAY_POLL_MS:-1000} + MAX_CONSUMER_RETRIES: ${MAX_CONSUMER_RETRIES:-3} + COUNTRY_NAMESPACE_ENABLED: ${COUNTRY_NAMESPACE_ENABLED:-true} + +x-app-service: &app-service + build: + context: . + dockerfile: Dockerfile + depends_on: + postgres: + condition: service_healthy + kafka: + condition: service_healthy + topic-init: + condition: service_completed_successfully + networks: [yape-shared] + +services: + postgres: + image: postgres:16-alpine + container_name: yape-postgres + profiles: ["core", "all", "db"] + environment: + POSTGRES_DB: ${DB_NAME:-yape} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: [yape-shared] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-yape}"] + interval: 5s + timeout: 5s + retries: 15 + + kafka: + image: apache/kafka:3.9.0 + container_name: yape-kafka + profiles: ["core", "all", "kafka"] + environment: + CLUSTER_ID: ${KAFKA_CLUSTER_ID:-4L6g3nShT-eMCtK--X86sw} + KAFKA_NODE_ID: "1" + KAFKA_PROCESS_ROLES: "broker,controller" + KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093" + KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://yape-kafka:9092" + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT" + KAFKA_CONTROLLER_QUORUM_VOTERS: "1@yape-kafka:9093" + KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER" + KAFKA_INTER_BROKER_LISTENER_NAME: "PLAINTEXT" + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" + KAFKA_NUM_PARTITIONS: "3" + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1" + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1" + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1" + KAFKA_LOG_DIRS: "/var/lib/kafka/data" + ports: + - "9092:9092" + volumes: + - kafka_data:/var/lib/kafka/data + networks: [yape-shared] + healthcheck: + test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list >/dev/null 2>&1"] + interval: 10s + timeout: 10s + retries: 12 + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: yape-kafka-ui + profiles: ["kafka-ui", "all"] + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: yape-kafka:9092 + ports: + - "8080:8080" + networks: [yape-shared] + depends_on: + kafka: + condition: service_healthy + + topic-init: + image: apache/kafka:3.9.0 + container_name: yape-topic-init + profiles: ["init-topics", "all"] + restart: "no" + depends_on: + kafka: + condition: service_healthy + environment: + KAFKA_BROKER: yape-kafka:9092 + SUPPORTED_COUNTRIES: ${SUPPORTED_COUNTRIES:-pe,mx} + TOPIC_PARTITIONS: ${TOPIC_PARTITIONS:-3} + TOPIC_REPLICATION_FACTOR: ${TOPIC_REPLICATION_FACTOR:-1} + volumes: + - ./scripts/init-topics.sh:/init-topics.sh:ro + networks: [yape-shared] + entrypoint: ["/bin/bash", "/init-topics.sh"] + + payment-api: + <<: *app-service + container_name: yape-payment-api + profiles: ["payment-api", "all"] + ports: + - "${PORT:-3000}:3000" + environment: + <<: *app-env + KAFKA_CLIENT_ID: ${PAYMENT_API_CLIENT_ID:-payment-api} + command: ["npm", "run", "start:payment-api"] + + outbox-relay: + <<: *app-service + container_name: yape-outbox-relay + profiles: ["outbox-relay", "all"] + environment: + <<: *app-env + KAFKA_CLIENT_ID: ${OUTBOX_RELAY_CLIENT_ID:-outbox-relay} + command: ["npm", "run", "start:outbox-relay"] + + fraud-consumer: + <<: *app-service + container_name: yape-fraud-consumer + profiles: ["fraud-consumer", "all"] + environment: + <<: *app-env + KAFKA_CLIENT_ID: ${FRAUD_CLIENT_ID:-fraud-consumer} + command: ["npm", "run", "start:fraud-consumer"] + + visual-demo: + build: + context: . + dockerfile: Dockerfile.visual-demo + container_name: yape-visual-demo + profiles: ["visual-demo", "all"] + depends_on: + kafka: + condition: service_healthy + payment-api: + condition: service_started + environment: + PORT: 4000 + PAYMENT_API_URL: http://payment-api:3000 + KAFKA_CONTAINER: yape-kafka + POSTGRES_CONTAINER: yape-postgres + SUPPORTED_COUNTRIES: ${SUPPORTED_COUNTRIES:-pe,mx} + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_NAME: ${DB_NAME:-yape} + ports: + - "4000:4000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: [yape-shared] + +networks: + yape-shared: + name: yape-shared + +volumes: + postgres_data: + kafka_data: diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 00000000..9860bf67 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,15 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + moduleFileExtensions: ['ts', 'js', 'json'], + moduleNameMapper: { + '^src/(.*)$': '/src/$1', + '^test/(.*)$': '/test/$1', + }, + clearMocks: true, +}; + +export default config; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..86eb26ec --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6724 @@ +{ + "name": "yape-challenge-payment-settlement", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yape-challenge-payment-settlement", + "version": "1.0.0", + "dependencies": { + "@nestjs/common": "^10.4.8", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.8", + "@nestjs/platform-express": "^10.4.8", + "@nestjs/typeorm": "^10.0.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "kafkajs": "^2.2.4", + "pg": "^8.13.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^0.3.20", + "uuid": "^11.0.3" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^22.10.2", + "@types/uuid": "^10.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "tsc-alias": "^1.8.10", + "typescript": "^5.7.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/common": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", + "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", + "license": "MIT", + "dependencies": { + "file-type": "20.4.1", + "iterare": "1.2.1", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.3.0.tgz", + "integrity": "sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==", + "license": "MIT", + "dependencies": { + "dotenv": "16.4.5", + "dotenv-expand": "10.0.0", + "lodash": "4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", + "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@nuxtjs/opencollective": "0.3.2", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "3.3.0", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", + "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", + "license": "MIT", + "dependencies": { + "body-parser": "1.20.4", + "cors": "2.8.5", + "express": "4.22.1", + "multer": "2.0.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + } + }, + "node_modules/@nestjs/typeorm": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", + "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", + "license": "MIT", + "dependencies": { + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0" + } + }, + "node_modules/@nestjs/typeorm/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nuxtjs/opencollective": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", + "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "consola": "^2.15.0", + "node-fetch": "^2.6.1" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/inflate/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kafkajs": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz", + "integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.41", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.41.tgz", + "integrity": "sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsc-alias": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", + "integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typeorm": { + "version": "0.3.28", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", + "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.2.0", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "dayjs": "^1.11.19", + "debug": "^4.4.3", + "dedent": "^1.7.0", + "dotenv": "^16.6.1", + "glob": "^10.5.0", + "reflect-metadata": "^0.2.2", + "sha.js": "^2.4.12", + "sql-highlight": "^6.1.0", + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typeorm/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/typeorm/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..e5b6ae15 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "yape-challenge-payment-settlement", + "version": "1.0.0", + "private": true, + "description": "Challenge 1 - Payment settlement pipeline", + "scripts": { + "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", + "start:payment-api": "node dist/apps/payment-api/main.js", + "start:outbox-relay": "node dist/apps/outbox-relay/main.js", + "start:fraud-consumer": "node dist/apps/fraud-consumer/main.js", + "start:ledger-consumer": "node dist/apps/ledger-consumer/main.js", + "start:status-saga": "node dist/apps/status-saga/main.js", + "start:visual-demo": "node test-visual/server.js", + "dev:payment-api": "ts-node src/apps/payment-api/main.ts", + "dev:outbox-relay": "ts-node src/apps/outbox-relay/main.ts", + "dev:fraud-consumer": "ts-node src/apps/fraud-consumer/main.ts", + "dev:ledger-consumer": "ts-node src/apps/ledger-consumer/main.ts", + "dev:status-saga": "ts-node src/apps/status-saga/main.ts", + "test": "jest --runInBand", + "test:watch": "jest --watch" + }, + "dependencies": { + "@nestjs/common": "^10.4.8", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.8", + "@nestjs/platform-express": "^10.4.8", + "@nestjs/typeorm": "^10.0.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "kafkajs": "^2.2.4", + "pg": "^8.13.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^0.3.20", + "uuid": "^11.0.3" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^22.10.2", + "@types/uuid": "^10.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "tsc-alias": "^1.8.10", + "typescript": "^5.7.2" + } +} diff --git a/run-init.md b/run-init.md new file mode 100644 index 00000000..cd8c1a99 --- /dev/null +++ b/run-init.md @@ -0,0 +1,167 @@ +# Run local con Docker + +Esta guía explica el flujo más simple para levantar y limpiar el proyecto usando los scripts incluidos. + +## Requisito + +Solo necesitas tener Docker Desktop instalado y corriendo. + +## Iniciar el proyecto + +Desde la raíz del proyecto ejecuta: + +```bash +./scripts/init-local.sh +``` + +El script te irá preguntando: + +```text +Use default DB password? +Add SUPPORTED_COUNTRIES? +Currency for pe? +Currency for mx? +Enable Kafka UI? +Enable Visual Demo? +Start Docker services now? +``` + +Para una primera corrida recomendada puedes usar: + +```text +SUPPORTED_COUNTRIES=pe,mx +pe -> PEN +mx -> MXN +Kafka UI -> yes +Visual Demo -> yes +Start Docker services -> yes +``` + +Al finalizar deberías ver URLs similares a: + +```text +Payment API : http://localhost:3000 +Kafka broker: localhost:9092 +Postgres : localhost:5432 +Kafka UI : http://localhost:8080 +Visual Demo : http://localhost:4000 +``` + +## Qué levanta el init + +El script levanta el stack core: + +```text +postgres +kafka +topic-init +payment-api +outbox-relay +fraud-consumer +``` + +Y levanta workers aislados por país: + +```text +yape-pe: ledger-consumer, status-saga +yape-mx: ledger-consumer, status-saga +``` + +Si configuraste más países, creará un proyecto Docker por cada país: + +```text +yape-co +yape-cl +yape-ar +``` + +## Ver el demo visual + +Abre: + +```text +http://localhost:4000 +``` +![Diagrama de arquitectura](visual-yape.png) +Escenarios recomendados para probar: + +```text +Success: settled +Fraud rejected: failed +Ledger down + retry +Timeout: pending honesto +DLT: payload invalido +Replay: idempotencia +MX caido, PE disponible +``` + +## Ver Kafka UI + +Abre: + +```text +http://localhost:8080 +``` + +Ahí puedes ver topics como: + +```text +pe.payments.payment.created.v1 +mx.payments.payment.created.v1 +pe.payments.payment.created.v1.dlt +mx.payments.payment.created.v1.dlt +``` + +## Borrar todo el Docker del proyecto + +Si quieres limpiar el laboratorio y volver a correr el init desde cero: + +```bash +./scripts/cleanup-docker.sh +``` + +El script mostrará un resumen de lo que va a eliminar: + +```text +containers +networks +volumes +imagenes locales del proyecto +``` + +Para evitar borrados accidentales, te pedirá escribir exactamente: + +```text +CONFIRM +``` + +Después de limpiar, puedes volver a iniciar todo con: + +```bash +./scripts/init-local.sh +``` + +## Cuándo usar cada script + +Usa `init-local.sh` cuando quieras: + +```text +crear .env inicial +crear .env.pe / .env.mx +configurar monedas por país +levantar Docker +abrir Kafka UI y Visual Demo +``` + +Usa `cleanup-docker.sh` cuando quieras: + +```text +apagar todo el proyecto +borrar volúmenes de Postgres/Kafka +probar el bootstrap desde cero +cambiar países o monedas desde cero +``` + +## Nota importante + +`cleanup-docker.sh` está diseñado para borrar recursos de este proyecto, no todo tu Docker local. Aun así, revisa el preview antes de escribir `CONFIRM`. diff --git a/scripts/README-run-kafka-scenario.md b/scripts/README-run-kafka-scenario.md new file mode 100644 index 00000000..cf83d0c5 --- /dev/null +++ b/scripts/README-run-kafka-scenario.md @@ -0,0 +1,88 @@ +# run-kafka-scenario.sh + +Script de demo E2E para ver eventos reales del pipeline en Kafka, con salida guiada en consola. + +## Qué hace + +`./scripts/run-kafka-scenario.sh` automatiza: + +1. Verifica/levanta servicios base necesarios (`kafka`, `payment-api`, `outbox-relay`, `fraud-consumer`, `ledger-consumer`, `status-saga`). +2. Crea un pago real vía `POST /payments`. +3. Observa topics Kafka del país seleccionado. +4. Consulta estado en `GET /payments/:id/status` hasta estado final o timeout. +5. Muestra resumen final del escenario. + +## Escenarios + +## 1) `success` (implementado) + +Flujo normal: +- `payment.created.v1` +- `fraud.assessed.v1` +- `ledger.posted.v1` +- `payment.settled.v1` + +Ejemplo: + +```bash +./scripts/run-kafka-scenario.sh --scenario success --country pe +``` + +## 2) `failure-stop-ledger` (implementado) + +Fallo operacional controlado: +- Detiene `ledger-consumer` del país. +- Crea el pago (debe quedarse `pending` mientras ledger está caído). +- Vuelve a levantar `ledger-consumer`. +- Verifica recuperación y cierre eventual. + +Ejemplo: + +```bash +./scripts/run-kafka-scenario.sh --scenario failure-stop-ledger --country pe +``` + +Alias válido: + +```bash +./scripts/run-kafka-scenario.sh --scenario failure --country pe +``` + +## 3) Escenarios recomendados a futuro (no implementados aún) + +- `failure-stop-fraud`: simular caída de fraude. +- `failure-dlt-invalid-payload`: inyectar payload inválido para observar retries + DLT. +- `country-parallel`: disparar pagos simultáneos en `pe` y `mx` para mostrar aislamiento por país. + +## Parámetros útiles + +```bash +./scripts/run-kafka-scenario.sh \ + --scenario success \ + --country pe \ + --wallet-id wallet-e2e-001 \ + --amount 10.50 \ + --currency PEN \ + --api-url http://localhost:3000 \ + --timeout 90 \ + --failure-hold 12 \ + --watch-timeout-ms 120000 +``` + +## Requisitos + +- Docker + Docker Compose +- `curl` +- `node` +- `.env` y `.env.` existentes + +## Salida esperada + +El script imprime: +- `paymentId` creado +- snapshots del estado eventual +- topics con eventos capturados para ese `paymentId` +- enlaces/comandos de logs para troubleshooting + +Kafka UI: +- [http://localhost:8080](http://localhost:8080) diff --git a/scripts/add-new-country.sh b/scripts/add-new-country.sh new file mode 100755 index 00000000..290c3576 --- /dev/null +++ b/scripts/add-new-country.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE=".env" +ENV_EXAMPLE=".env.example" +COUNTRY_TEMPLATE=".env.country.example" +COUNTRY_COMPOSE_FILE="docker-compose.country.yml" +CORE_COMPOSE_FILE="docker-compose.yml" + +log() { + printf '[country] %s\n' "$*" +} + +error() { + printf '[country][error] %s\n' "$*" >&2 +} + +ask_yes_no() { + local question="$1" + local default_answer="$2" + + while true; do + local suffix="[y/N]" + if [[ "$default_answer" == "y" ]]; then + suffix="[Y/n]" + fi + + read -r -p "$question $suffix: " reply + reply="${reply:-$default_answer}" + + case "$reply" in + y|Y|yes|YES) + return 0 + ;; + n|N|no|NO) + return 1 + ;; + *) + echo "Please answer y or n." + ;; + esac + done +} + +normalize_country() { + echo "$1" | tr '[:upper:]' '[:lower:]' | tr -d ' ' +} + +validate_country() { + local country="$1" + [[ "$country" =~ ^[a-z]{2}$ ]] +} + +default_currency_for_country() { + case "$1" in + pe) echo "PEN" ;; + mx) echo "MXN" ;; + co) echo "COP" ;; + cl) echo "CLP" ;; + ar) echo "ARS" ;; + br) echo "BRL" ;; + ec) echo "USD" ;; + *) echo "$(echo "$1" | tr '[:lower:]' '[:upper:]')" ;; + esac +} + +validate_currency() { + local currency="$1" + [[ "$currency" =~ ^[A-Z]{3}$ ]] +} + +ensure_env_file() { + if [[ -f "$ENV_FILE" ]]; then + return + fi + + if [[ ! -f "$ENV_EXAMPLE" ]]; then + error "No existe ${ENV_FILE} ni ${ENV_EXAMPLE}." + exit 1 + fi + + cp "$ENV_EXAMPLE" "$ENV_FILE" + log "Se creó ${ENV_FILE} desde ${ENV_EXAMPLE}." +} + +get_env_var() { + local key="$1" + grep -E "^[[:space:]]*${key}=" "$ENV_FILE" | head -n 1 | cut -d '=' -f 2- || true +} + +set_env_var() { + local key="$1" + local value="$2" + local tmp + + tmp="$(mktemp)" + awk -v key="$key" -v value="$value" ' + BEGIN { updated = 0 } + $0 ~ "^[[:space:]]*" key "=" { + if (!updated) { + print key "=" value + updated = 1 + } + next + } + { print } + END { + if (!updated) { + print key "=" value + } + } + ' "$ENV_FILE" > "$tmp" + mv "$tmp" "$ENV_FILE" +} + +get_env_var_from_file() { + local file="$1" + local key="$2" + grep -E "^[[:space:]]*${key}=" "$file" | head -n 1 | cut -d '=' -f 2- || true +} + +set_env_var_in_file() { + local file="$1" + local key="$2" + local value="$3" + local tmp + + tmp="$(mktemp)" + awk -v key="$key" -v value="$value" ' + BEGIN { updated = 0 } + $0 ~ "^[[:space:]]*" key "=" { + if (!updated) { + print key "=" value + updated = 1 + } + next + } + { print } + END { + if (!updated) { + print key "=" value + } + } + ' "$file" > "$tmp" + mv "$tmp" "$file" +} + +ask_country_currency() { + local country="$1" + local country_env_file="$2" + local current current_country default_currency input + + current="$(get_env_var_from_file "$country_env_file" "COUNTRY_CURRENCY")" + current_country="$(get_env_var_from_file "$country_env_file" "COUNTRY_CODE")" + if [[ "$current_country" == "$country" && -n "$current" ]]; then + default_currency="$current" + else + default_currency="$(default_currency_for_country "$country")" + fi + + while true; do + read -r -p "Moneda para ${country} (ISO 4217, ejemplo: PEN, MXN, COP) [default: ${default_currency}]: " input + input="${input:-$default_currency}" + input="$(echo "$input" | tr '[:lower:]' '[:upper:]' | tr -d ' ')" + + if validate_currency "$input"; then + printf '%s' "$input" + return + fi + + echo "La moneda debe tener exactamente 3 letras (ejemplo: PEN)." >&2 + done +} + +append_country_to_supported() { + local country="$1" + local current + current="$(get_env_var "SUPPORTED_COUNTRIES")" + current="${current:-pe,mx}" + current="$(echo "$current" | tr '[:upper:]' '[:lower:]' | tr -d ' ' | sed -E 's/,+/,/g; s/^,+//; s/,+$//')" + + if echo ",$current," | grep -q ",$country,"; then + log "${country} ya está en SUPPORTED_COUNTRIES (${current})." + return + fi + + local updated + if [[ -z "$current" ]]; then + updated="$country" + else + updated="${current},${country}" + fi + + set_env_var "SUPPORTED_COUNTRIES" "$updated" + log "Se actualizó SUPPORTED_COUNTRIES=${updated}." +} + +prepare_country_env_file() { + local country="$1" + local currency="$2" + local country_env_file=".env.${country}" + + if [[ ! -f "$country_env_file" ]]; then + if [[ -f "$COUNTRY_TEMPLATE" ]]; then + cp "$COUNTRY_TEMPLATE" "$country_env_file" + else + printf 'COUNTRY_CODE=%s\n' "$country" > "$country_env_file" + fi + log "Se creó ${country_env_file}." + fi + + set_env_var_in_file "$country_env_file" "COUNTRY_CODE" "$country" + set_env_var_in_file "$country_env_file" "COUNTRY_CURRENCY" "$currency" + + log "Se configuró COUNTRY_CODE=${country} y COUNTRY_CURRENCY=${currency} en ${country_env_file}." +} + +ensure_docker_available() { + if ! command -v docker >/dev/null 2>&1; then + error "docker no está disponible en PATH." + exit 1 + fi + + if ! docker compose version >/dev/null 2>&1; then + error "docker compose no está disponible." + exit 1 + fi +} + +ensure_topics_for_country() { + local country="$1" + + if [[ ! -f "$CORE_COMPOSE_FILE" ]]; then + error "No existe ${CORE_COMPOSE_FILE}. No puedo inicializar topics." + exit 1 + fi + + log "Inicializando topics Kafka para ${country} (idempotente)..." + + if ! docker ps --format '{{.Names}}' | grep -Fxq "yape-kafka"; then + if ask_yes_no "Kafka no está corriendo. ¿Levantar servicio kafka del core ahora?" "y"; then + docker compose --env-file "$ENV_FILE" --profile core up -d kafka + else + error "No se puede inicializar topics sin Kafka activo." + exit 1 + fi + fi + + SUPPORTED_COUNTRIES="$country" docker compose \ + --env-file "$ENV_FILE" \ + run --rm --no-deps topic-init +} + +start_country_services() { + local country="$1" + local project="yape-${country}" + local country_env_file=".env.${country}" + + if [[ ! -f "$COUNTRY_COMPOSE_FILE" ]]; then + error "No existe ${COUNTRY_COMPOSE_FILE}." + exit 1 + fi + + log "Levantando servicios aislados para ${country} en proyecto ${project}..." + docker compose -p "$project" \ + -f "$COUNTRY_COMPOSE_FILE" \ + --env-file "$ENV_FILE" \ + --env-file "$country_env_file" \ + --profile country \ + up -d --build +} + +main() { + ensure_docker_available + ensure_env_file + + local country_input="${1:-}" + if [[ -z "$country_input" ]]; then + read -r -p "Código de país (2 letras, ejemplo: pe, mx, co): " country_input + fi + + local country + country="$(normalize_country "$country_input")" + + if ! validate_country "$country"; then + error "Código inválido: '${country_input}'. Usa exactamente 2 letras (ej: pe, mx, co)." + exit 1 + fi + + log "Preparando onboarding para país: ${country}." + + append_country_to_supported "$country" + + local country_env_file=".env.${country}" + if [[ ! -f "$country_env_file" && -f "$COUNTRY_TEMPLATE" ]]; then + cp "$COUNTRY_TEMPLATE" "$country_env_file" + elif [[ ! -f "$country_env_file" ]]; then + printf 'COUNTRY_CODE=%s\n' "$country" > "$country_env_file" + fi + + local currency + currency="$(ask_country_currency "$country" "$country_env_file")" + prepare_country_env_file "$country" "$currency" + + if ask_yes_no "¿Inicializar topics de Kafka para ${country} ahora?" "y"; then + ensure_topics_for_country "$country" + else + log "Se omitió inicialización de topics." + fi + + if ask_yes_no "¿Levantar ahora ledger-consumer y status-saga para ${country}?" "y"; then + start_country_services "$country" + log "País ${country} agregado sin afectar países existentes." + log "Comando usado: docker compose -p yape-${country} -f docker-compose.country.yml --env-file .env --env-file .env.${country} --profile country up -d --build" + else + log "Listo. Para levantar manualmente ejecuta:" + echo "docker compose -p yape-${country} -f docker-compose.country.yml --env-file .env --env-file .env.${country} --profile country up -d --build" + fi +} + +main "$@" diff --git a/scripts/cleanup-docker.sh b/scripts/cleanup-docker.sh new file mode 100755 index 00000000..dc78c5d9 --- /dev/null +++ b/scripts/cleanup-docker.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE=".env" +CORE_COMPOSE_FILE="docker-compose.yml" +COUNTRY_COMPOSE_FILE="docker-compose.country.yml" +CORE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}" + +CORE_SERVICES=( + "yape-postgres" + "yape-kafka" + "yape-kafka-ui" + "yape-topic-init" + "yape-payment-api" + "yape-outbox-relay" + "yape-fraud-consumer" + "yape-visual-demo" +) + +log() { + printf '[cleanup] %s\n' "$*" +} + +warn() { + printf '[cleanup][warn] %s\n' "$*" >&2 +} + +error() { + printf '[cleanup][error] %s\n' "$*" >&2 +} + +require_docker() { + if ! command -v docker >/dev/null 2>&1; then + error "docker no está disponible en PATH." + exit 1 + fi + + if ! docker compose version >/dev/null 2>&1; then + error "docker compose no está disponible." + exit 1 + fi +} + +project_has_resources() { + local project="$1" + + if docker ps -a --filter "label=com.docker.compose.project=${project}" --format '{{.ID}}' | grep -q '.'; then + return 0 + fi + + if docker volume ls --filter "label=com.docker.compose.project=${project}" --format '{{.Name}}' | grep -q '.'; then + return 0 + fi + + if docker network ls --filter "label=com.docker.compose.project=${project}" --format '{{.Name}}' | grep -q '.'; then + return 0 + fi + + return 1 +} + +normalize_countries() { + local raw="$1" + echo "$raw" | tr '[:upper:]' '[:lower:]' | tr -d ' ' | sed -E 's/,+/,/g; s/^,+//; s/,+$//' +} + +get_supported_countries() { + local countries="" + + if [[ -f "$ENV_FILE" ]]; then + countries="$(grep -E '^[[:space:]]*SUPPORTED_COUNTRIES=' "$ENV_FILE" | head -n 1 | cut -d '=' -f 2- || true)" + fi + + countries="$(normalize_countries "${countries:-}")" + + if [[ -z "$countries" ]]; then + countries="pe,mx" + fi + + printf '%s' "$countries" +} + +collect_country_codes() { + local supported_csv="$1" + local country + declare -A uniq=() + + IFS=',' read -ra from_env <<< "$supported_csv" + for country in "${from_env[@]}"; do + [[ -n "$country" ]] && uniq["$country"]=1 + done + + while IFS= read -r file; do + country="${file##*.env.}" + if [[ "$country" =~ ^[a-z]{2}$ ]]; then + uniq["$country"]=1 + fi + done < <(find "$ROOT_DIR" -maxdepth 1 -type f -name '.env.*' ! -name '.env.example' ! -name '.env.country.example' | sort) + + local out=() + for country in "${!uniq[@]}"; do + out+=("$country") + done + + if ((${#out[@]} > 0)); then + printf '%s\n' "${out[@]}" | sort + fi +} + +list_container_candidates() { + log "Contenedores de este proyecto detectados:" + + for c in "${CORE_SERVICES[@]}"; do + if docker ps -a --format '{{.Names}}' | grep -Fxq "$c"; then + echo " - $c" + fi + done + + local country + while IFS= read -r country; do + [[ -z "$country" ]] && continue + local project="yape-${country}" + + while IFS= read -r name; do + [[ -n "$name" ]] && echo " - $name" + done < <(docker ps -a --filter "label=com.docker.compose.project=${project}" --format '{{.Names}}') + done < <(collect_country_codes "$(get_supported_countries)") +} + +list_resource_candidates() { + log "Recursos potenciales del proyecto:" + + echo " Redes:" + while IFS= read -r network; do + [[ -n "$network" ]] && echo " - $network" + done < <(docker network ls --format '{{.Name}}' | grep -E '^(yape-shared|yape-[a-z]{2}_default|yape-challenge_default)$' || true) + + echo " Volúmenes (labels compose):" + while IFS= read -r volume; do + [[ -n "$volume" ]] && echo " - $volume" + done < <(docker volume ls --filter 'label=com.docker.compose.project' --format '{{.Name}}' | grep -E '(yape|yape-challenge|yape-[a-z]{2})' || true) + + echo " Imágenes locales candidatas (built por compose):" + while IFS= read -r image; do + [[ -n "$image" ]] && echo " - $image" + done < <(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '(yape|yape-challenge)' || true) +} + +down_country_projects() { + local country + + while IFS= read -r country; do + [[ -z "$country" ]] && continue + local project="yape-${country}" + + if ! project_has_resources "$project"; then + log "Proyecto país ${project} sin recursos activos. Se omite." + continue + fi + + log "Bajando proyecto país ${project}..." + + if [[ -f "$ENV_FILE" ]]; then + COUNTRY_CODE="$country" docker compose -p "$project" \ + -f "$COUNTRY_COMPOSE_FILE" \ + --env-file "$ENV_FILE" \ + down -v --remove-orphans || true + else + COUNTRY_CODE="$country" docker compose -p "$project" \ + -f "$COUNTRY_COMPOSE_FILE" \ + down -v --remove-orphans || true + fi + done < <(collect_country_codes "$(get_supported_countries)") +} + +down_core_project() { + if [[ ! -f "$CORE_COMPOSE_FILE" ]]; then + warn "No se encontró ${CORE_COMPOSE_FILE}; se omite limpieza core por compose." + return + fi + + if ! project_has_resources "$CORE_PROJECT_NAME"; then + log "Proyecto core ${CORE_PROJECT_NAME} sin recursos activos. Se omite." + return + fi + + log "Bajando stack core del proyecto..." + + if [[ -f "$ENV_FILE" ]]; then + docker compose -p "$CORE_PROJECT_NAME" -f "$CORE_COMPOSE_FILE" --env-file "$ENV_FILE" --profile all down -v --remove-orphans --rmi local || true + else + docker compose -p "$CORE_PROJECT_NAME" -f "$CORE_COMPOSE_FILE" --profile all down -v --remove-orphans --rmi local || true + fi +} + +remove_leftover_project_containers() { + log "Eliminando contenedores huérfanos del proyecto (si existen)..." + + local ids="" + + ids="$(docker ps -a --filter "label=com.docker.compose.project=${CORE_PROJECT_NAME}" --format '{{.ID}}')" + if [[ -n "$ids" ]]; then + echo "$ids" | xargs docker rm -f >/dev/null 2>&1 || true + fi + + for c in "${CORE_SERVICES[@]}"; do + if docker ps -a --format '{{.Names}}' | grep -Fxq "$c"; then + docker rm -f "$c" >/dev/null 2>&1 || true + fi + done + + local country + while IFS= read -r country; do + [[ -z "$country" ]] && continue + local project="yape-${country}" + ids="$(docker ps -a --filter "label=com.docker.compose.project=${project}" --format '{{.ID}}')" + if [[ -n "$ids" ]]; then + echo "$ids" | xargs docker rm -f >/dev/null 2>&1 || true + fi + done < <(collect_country_codes "$(get_supported_countries)") +} + +remove_leftover_project_images() { + log "Eliminando imágenes locales del proyecto (si existen)..." + + local image + while IFS= read -r image; do + [[ -n "$image" ]] && docker image rm -f "$image" >/dev/null 2>&1 || true + done < <(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E "^${CORE_PROJECT_NAME}-(payment-api|outbox-relay|fraud-consumer|visual-demo):" || true) + + local country + while IFS= read -r country; do + [[ -z "$country" ]] && continue + while IFS= read -r image; do + [[ -n "$image" ]] && docker image rm -f "$image" >/dev/null 2>&1 || true + done < <(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E "^yape-${country}-(ledger-consumer|status-saga):" || true) + done < <(collect_country_codes "$(get_supported_countries)") +} + +remove_shared_network_if_unused() { + if ! docker network inspect yape-shared >/dev/null 2>&1; then + return + fi + + local attached + attached="$(docker network inspect yape-shared --format '{{len .Containers}}' 2>/dev/null || echo "0")" + + if [[ "$attached" == "0" ]]; then + log "Eliminando red yape-shared (sin contenedores conectados)..." + docker network rm yape-shared >/dev/null 2>&1 || true + else + warn "No se elimina yape-shared porque tiene ${attached} contenedor(es) conectado(s)." + fi +} + +main() { + require_docker + + echo "" + echo "Este script está a punto de borrar recursos Docker del proyecto local yape-challenge:" + echo "- Contenedores (core y por país)" + echo "- Redes de compose del proyecto" + echo "- Volúmenes de compose del proyecto" + echo "- Imágenes locales construidas por compose del proyecto" + echo "" + + list_container_candidates + list_resource_candidates + + echo "" + echo "Escribe CONFIRM para ejecutar el borrado." + read -r -p "> " confirm + + if [[ "$confirm" != "CONFIRM" ]]; then + log "Operación cancelada. No se eliminó nada." + exit 0 + fi + + down_country_projects + down_core_project + remove_leftover_project_containers + remove_leftover_project_images + remove_shared_network_if_unused + + log "Limpieza completada." +} + +main "$@" diff --git a/scripts/init-local.sh b/scripts/init-local.sh new file mode 100755 index 00000000..54a92ba3 --- /dev/null +++ b/scripts/init-local.sh @@ -0,0 +1,360 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE=".env" +ENV_EXAMPLE=".env.example" +COUNTRY_TEMPLATE=".env.country.example" + +log() { + printf '[init] %s\n' "$*" >&2 +} + +error() { + printf '[init][error] %s\n' "$*" >&2 +} + +ask_yes_no() { + local question="$1" + local default_answer="$2" + + while true; do + local suffix="[y/N]" + if [[ "$default_answer" == "y" ]]; then + suffix="[Y/n]" + fi + + read -r -p "$question $suffix: " reply + reply="${reply:-$default_answer}" + + case "$reply" in + y|Y|yes|YES) + return 0 + ;; + n|N|no|NO) + return 1 + ;; + *) + echo "Please answer y or n." + ;; + esac + done +} + +normalize_countries() { + local raw="$1" + echo "$raw" | tr '[:upper:]' '[:lower:]' | tr -d ' ' | sed -E 's/,+/,/g; s/^,+//; s/,+$//' +} + +set_env_var() { + local file="$1" + local key="$2" + local value="$3" + local tmp + + tmp="$(mktemp)" + awk -v key="$key" -v value="$value" ' + BEGIN { updated = 0 } + $0 ~ "^[[:space:]]*" key "=" { + if (!updated) { + print key "=" value + updated = 1 + } + next + } + { print } + END { + if (!updated) { + print key "=" value + } + } + ' "$file" > "$tmp" + mv "$tmp" "$file" +} + +get_env_var() { + local file="$1" + local key="$2" + local value + + value="$(grep -E "^[[:space:]]*${key}=" "$file" | head -n 1 | cut -d '=' -f 2- || true)" + printf '%s' "$value" +} + +validate_countries() { + local countries_csv="$1" + [[ "$countries_csv" =~ ^[a-z]{2}(,[a-z]{2})*$ ]] +} + +default_currency_for_country() { + case "$1" in + pe) echo "PEN" ;; + mx) echo "MXN" ;; + co) echo "COP" ;; + cl) echo "CLP" ;; + ar) echo "ARS" ;; + br) echo "BRL" ;; + ec) echo "USD" ;; + *) echo "$(echo "$1" | tr '[:lower:]' '[:upper:]')" ;; + esac +} + +validate_currency() { + local currency="$1" + [[ "$currency" =~ ^[A-Z]{3}$ ]] +} + +ask_country_currency() { + local country="$1" + local file="$2" + local current current_country default_currency input + + current="$(get_env_var "$file" "COUNTRY_CURRENCY")" + current_country="$(get_env_var "$file" "COUNTRY_CODE")" + if [[ "$current_country" == "$country" && -n "$current" ]]; then + default_currency="$current" + else + default_currency="$(default_currency_for_country "$country")" + fi + + while true; do + read -r -p "Currency for ${country} (ISO 4217, example: PEN, MXN, COP) [default: ${default_currency}]: " input + input="${input:-$default_currency}" + input="$(echo "$input" | tr '[:lower:]' '[:upper:]' | tr -d ' ')" + + if validate_currency "$input"; then + printf '%s' "$input" + return + fi + + echo "Currency must be exactly 3 uppercase letters (example: PEN)." >&2 + done +} + +ensure_env_file() { + if [[ -f "$ENV_FILE" ]]; then + log "Found existing ${ENV_FILE}." + return + fi + + if [[ ! -f "$ENV_EXAMPLE" ]]; then + error "${ENV_EXAMPLE} not found. Cannot bootstrap ${ENV_FILE}." + exit 1 + fi + + cp "$ENV_EXAMPLE" "$ENV_FILE" + log "Created ${ENV_FILE} from ${ENV_EXAMPLE}." +} + +configure_db_password() { + local current_password + current_password="$(get_env_var "$ENV_FILE" "DB_PASSWORD")" + current_password="${current_password:-postgres}" + + if ask_yes_no "Use default DB password (${current_password})?" "y"; then + set_env_var "$ENV_FILE" "DB_PASSWORD" "$current_password" + return + fi + + local new_password + while true; do + read -r -s -p "Enter DB password: " new_password + echo + if [[ -n "$new_password" ]]; then + break + fi + echo "Password cannot be empty." + done + + set_env_var "$ENV_FILE" "DB_PASSWORD" "$new_password" + log "Updated DB_PASSWORD in ${ENV_FILE}." +} + +configure_countries() { + local current_countries input countries_csv + current_countries="$(get_env_var "$ENV_FILE" "SUPPORTED_COUNTRIES")" + current_countries="$(normalize_countries "${current_countries:-pe,mx}")" + + read -r -p "Add SUPPORTED_COUNTRIES (example: pe,mx,co) [default: ${current_countries}]: " input + input="${input:-$current_countries}" + countries_csv="$(normalize_countries "$input")" + + if [[ -z "$countries_csv" ]] || ! validate_countries "$countries_csv"; then + error "Invalid format for countries. Use two-letter codes separated by comma (example: pe,mx,co)." + exit 1 + fi + + set_env_var "$ENV_FILE" "SUPPORTED_COUNTRIES" "$countries_csv" + set_env_var "$ENV_FILE" "COUNTRY_NAMESPACE_ENABLED" "true" + set_env_var "$ENV_FILE" "DB_HOST" "yape-postgres" + set_env_var "$ENV_FILE" "KAFKA_BROKERS" "yape-kafka:9092" + + log "Configured SUPPORTED_COUNTRIES=${countries_csv} in ${ENV_FILE}." + printf '%s' "$countries_csv" +} + +create_country_env_files() { + local countries_csv="$1" + local country + IFS=',' read -ra countries <<< "$countries_csv" + + for country in "${countries[@]}"; do + local file=".env.${country}" + + if [[ -f "$file" ]]; then + if ! ask_yes_no "${file} already exists. Update COUNTRY_CODE and COUNTRY_CURRENCY?" "y"; then + log "Keeping existing ${file}." + continue + fi + else + if [[ -f "$COUNTRY_TEMPLATE" ]]; then + cp "$COUNTRY_TEMPLATE" "$file" + else + printf 'COUNTRY_CODE=%s\n' "$country" > "$file" + fi + fi + + local currency + currency="$(ask_country_currency "$country" "$file")" + + set_env_var "$file" "COUNTRY_CODE" "$country" + set_env_var "$file" "COUNTRY_CURRENCY" "$currency" + log "Prepared ${file} with COUNTRY_CODE=${country} and COUNTRY_CURRENCY=${currency}." + done +} + +ensure_docker_available() { + if ! command -v docker >/dev/null 2>&1; then + error "docker not found in PATH. Install Docker first." + exit 1 + fi + + if ! docker compose version >/dev/null 2>&1; then + error "docker compose not available. Please enable Docker Compose plugin." + exit 1 + fi +} + +start_stack() { + local countries_csv="$1" + local with_kafka_ui="$2" + local with_visual_demo="$3" + + log "Starting core stack..." + docker compose --env-file "$ENV_FILE" \ + --profile core \ + --profile init-topics \ + --profile payment-api \ + --profile outbox-relay \ + --profile fraud-consumer \ + up -d --build + + if [[ "$with_kafka_ui" == "yes" ]]; then + log "Starting Kafka UI..." + docker compose --env-file "$ENV_FILE" --profile kafka --profile kafka-ui up -d + fi + + if [[ "$with_visual_demo" == "yes" ]]; then + log "Starting Visual Demo..." + docker compose --env-file "$ENV_FILE" \ + --profile core \ + --profile init-topics \ + --profile payment-api \ + --profile visual-demo \ + up -d --build visual-demo + fi + + local country + IFS=',' read -ra countries <<< "$countries_csv" + for country in "${countries[@]}"; do + local country_env_file=".env.${country}" + log "Starting country workers for ${country}..." + docker compose -p "yape-${country}" \ + -f docker-compose.country.yml \ + --env-file "$ENV_FILE" \ + --env-file "$country_env_file" \ + --profile country \ + up -d --build + done + + log "Bootstrap complete." +} + +print_runtime_info() { + local countries_csv="$1" + local with_kafka_ui="$2" + local with_visual_demo="$3" + local api_port db_port + + api_port="$(get_env_var "$ENV_FILE" "PORT")" + api_port="${api_port:-3000}" + + db_port="$(get_env_var "$ENV_FILE" "DB_PORT")" + db_port="${db_port:-5432}" + + echo "" + echo "=== Runtime info ===" + echo "Payment API : http://localhost:${api_port}" + echo "Kafka broker: localhost:9092" + echo "Postgres : localhost:${db_port}" + + if [[ "$with_kafka_ui" == "yes" ]]; then + echo "Kafka UI : http://localhost:8080" + else + echo "Kafka UI : disabled (run: docker compose --env-file .env --profile kafka --profile kafka-ui up -d)" + fi + + if [[ "$with_visual_demo" == "yes" ]]; then + echo "Visual Demo : http://localhost:4000" + else + echo "Visual Demo : disabled (run: docker compose --env-file .env --profile core --profile init-topics --profile payment-api --profile visual-demo up -d --build visual-demo)" + fi + + echo "Country workers:" + IFS=',' read -ra countries <<< "$countries_csv" + for country in "${countries[@]}"; do + local currency + currency="$(get_env_var ".env.${country}" "COUNTRY_CURRENCY")" + currency="${currency:-$(default_currency_for_country "$country")}" + echo " - ${country}: project yape-${country} (currency ${currency}, ledger-consumer, status-saga)" + done +} + +main() { + log "Interactive bootstrap started." + ensure_docker_available + ensure_env_file + configure_db_password + + local countries_csv + countries_csv="$(configure_countries)" + create_country_env_files "$countries_csv" + + local kafka_ui_choice="no" + if ask_yes_no "Enable Kafka UI (http://localhost:8080)?" "y"; then + kafka_ui_choice="yes" + fi + + local visual_demo_choice="no" + if ask_yes_no "Enable Visual Demo (http://localhost:4000)?" "y"; then + visual_demo_choice="yes" + fi + + if ask_yes_no "Start Docker services now?" "y"; then + start_stack "$countries_csv" "$kafka_ui_choice" "$visual_demo_choice" + print_runtime_info "$countries_csv" "$kafka_ui_choice" "$visual_demo_choice" + else + log "Setup files prepared. You can start manually with:" + echo " docker compose --env-file .env --profile all up -d --build" + IFS=',' read -ra countries <<< "$countries_csv" + for country in "${countries[@]}"; do + echo " docker compose -p yape-${country} -f docker-compose.country.yml --env-file .env --env-file .env.${country} --profile country up -d --build" + done + fi + + log "Done." +} + +main "$@" diff --git a/scripts/init-topics.sh b/scripts/init-topics.sh new file mode 100755 index 00000000..210e7010 --- /dev/null +++ b/scripts/init-topics.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +BROKER="${KAFKA_BROKER:-kafka:9092}" +COUNTRIES_RAW="${SUPPORTED_COUNTRIES:-pe,mx}" +PARTITIONS="${TOPIC_PARTITIONS:-3}" +REPLICATION="${TOPIC_REPLICATION_FACTOR:-1}" + +BASE_TOPICS=( + "payment.created.v1" + "fraud.assessed.v1" + "ledger.posted.v1" + "payment.settled.v1" + "payment.failed.v1" +) + +read -r -a COUNTRIES <<< "$(echo "${COUNTRIES_RAW}" | tr ',' ' ')" + +echo "Initializing topics on broker ${BROKER} for countries: ${COUNTRIES[*]}" + +for country in "${COUNTRIES[@]}"; do + normalized_country="$(echo "${country}" | tr '[:upper:]' '[:lower:]' | xargs)" + + for base in "${BASE_TOPICS[@]}"; do + topic="${normalized_country}.payments.${base}" + dlt_topic="${topic}.dlt" + + /opt/kafka/bin/kafka-topics.sh \ + --bootstrap-server "${BROKER}" \ + --create \ + --if-not-exists \ + --topic "${topic}" \ + --partitions "${PARTITIONS}" \ + --replication-factor "${REPLICATION}" + + /opt/kafka/bin/kafka-topics.sh \ + --bootstrap-server "${BROKER}" \ + --create \ + --if-not-exists \ + --topic "${dlt_topic}" \ + --partitions "${PARTITIONS}" \ + --replication-factor "${REPLICATION}" + + echo "Ensured topics: ${topic} and ${dlt_topic}" + done +done + +echo "Topic initialization complete." diff --git a/scripts/run-kafka-scenario.sh b/scripts/run-kafka-scenario.sh new file mode 100755 index 00000000..019f92e1 --- /dev/null +++ b/scripts/run-kafka-scenario.sh @@ -0,0 +1,467 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE=".env" +COUNTRY_COMPOSE_FILE="docker-compose.country.yml" + +SCENARIO="success" +COUNTRY="pe" +WALLET_ID="wallet-e2e-001" +AMOUNT="10.50" +CURRENCY="PEN" +API_URL="http://localhost:3000" +TIMEOUT_SECONDS=90 +FAILURE_HOLD_SECONDS=12 +WATCH_TIMEOUT_MS=120000 + +TMP_DIR="" +PAYMENT_ID="" +LEDGER_STOPPED=0 + +if [[ -t 1 ]]; then + C_RESET='\033[0m' + C_INFO='\033[1;34m' + C_OK='\033[1;32m' + C_WARN='\033[1;33m' + C_ERR='\033[1;31m' + C_DIM='\033[2m' +else + C_RESET='' + C_INFO='' + C_OK='' + C_WARN='' + C_ERR='' + C_DIM='' +fi + +log_info() { printf "%b[scenario] %s%b\n" "$C_INFO" "$*" "$C_RESET"; } +log_ok() { printf "%b[scenario] %s%b\n" "$C_OK" "$*" "$C_RESET"; } +log_warn() { printf "%b[scenario] %s%b\n" "$C_WARN" "$*" "$C_RESET"; } +log_err() { printf "%b[scenario] %s%b\n" "$C_ERR" "$*" "$C_RESET" >&2; } + +usage() { + cat < + --country (default: pe) + --wallet-id (default: wallet-e2e-001) + --amount (default: 10.50) + --currency (default: PEN) + --api-url (default: http://localhost:3000) + --timeout (default: 90) + --failure-hold (default: 12) + --watch-timeout-ms (default: 120000) + -h, --help + +Examples: + ./scripts/run-kafka-scenario.sh --scenario success --country pe + ./scripts/run-kafka-scenario.sh --scenario failure-stop-ledger --country pe +USAGE +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + log_err "Missing command: ${cmd}" + exit 1 + fi +} + +normalize_country() { + echo "$1" | tr '[:upper:]' '[:lower:]' | tr -d ' ' +} + +country_upper() { + echo "$1" | tr '[:lower:]' '[:upper:]' +} + +country_compose() { + docker compose -p "yape-${COUNTRY}" \ + -f "$COUNTRY_COMPOSE_FILE" \ + --env-file "$ENV_FILE" \ + --env-file ".env.${COUNTRY}" \ + "$@" +} + +json_get_field() { + local field="$1" + node -e "let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{const v=JSON.parse(d)?.['${field}']; process.stdout.write(v===undefined||v===null?'':String(v));}catch{process.stdout.write('')}});" +} + +cleanup() { + if [[ "$LEDGER_STOPPED" == "1" ]]; then + log_warn "Restoring ledger-consumer for country '${COUNTRY}'..." + country_compose up -d ledger-consumer >/dev/null 2>&1 || true + fi + + if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then + rm -rf "$TMP_DIR" + fi +} + +trap cleanup EXIT + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --scenario) + SCENARIO="${2:-}" + shift 2 + ;; + --country) + COUNTRY="${2:-}" + shift 2 + ;; + --wallet-id) + WALLET_ID="${2:-}" + shift 2 + ;; + --amount) + AMOUNT="${2:-}" + shift 2 + ;; + --currency) + CURRENCY="${2:-}" + shift 2 + ;; + --api-url) + API_URL="${2:-}" + shift 2 + ;; + --timeout) + TIMEOUT_SECONDS="${2:-}" + shift 2 + ;; + --failure-hold) + FAILURE_HOLD_SECONDS="${2:-}" + shift 2 + ;; + --watch-timeout-ms) + WATCH_TIMEOUT_MS="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + log_err "Unknown argument: $1" + usage + exit 1 + ;; + esac + done + + COUNTRY="$(normalize_country "$COUNTRY")" + + if [[ "$SCENARIO" == "failure" ]]; then + SCENARIO="failure-stop-ledger" + fi + + if [[ ! "$COUNTRY" =~ ^[a-z]{2}$ ]]; then + log_err "Invalid country '${COUNTRY}'. Use 2-letter code (pe, mx, co...)." + exit 1 + fi + + if [[ "$SCENARIO" != "success" && "$SCENARIO" != "failure-stop-ledger" ]]; then + log_err "Invalid scenario '${SCENARIO}'. Allowed: success, failure-stop-ledger" + exit 1 + fi + + if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || [[ "$TIMEOUT_SECONDS" -lt 10 ]]; then + log_err "--timeout must be an integer >= 10" + exit 1 + fi + + if [[ ! "$FAILURE_HOLD_SECONDS" =~ ^[0-9]+$ ]] || [[ "$FAILURE_HOLD_SECONDS" -lt 1 ]]; then + log_err "--failure-hold must be an integer >= 1" + exit 1 + fi +} + +ensure_files() { + if [[ ! -f "$ENV_FILE" ]]; then + log_err "Missing ${ENV_FILE}. Run ./scripts/init-local.sh first." + exit 1 + fi + + if [[ ! -f ".env.${COUNTRY}" ]]; then + log_err "Missing .env.${COUNTRY}. Create it (or run ./scripts/add-new-country.sh ${COUNTRY})." + exit 1 + fi + + if [[ ! -f "$COUNTRY_COMPOSE_FILE" ]]; then + log_err "Missing ${COUNTRY_COMPOSE_FILE}." + exit 1 + fi +} + +ensure_stack() { + log_info "Ensuring core services are up (kafka, api, relay, fraud)..." + docker compose --env-file "$ENV_FILE" \ + --profile core \ + --profile init-topics \ + --profile payment-api \ + --profile outbox-relay \ + --profile fraud-consumer \ + up -d --build postgres kafka topic-init payment-api outbox-relay fraud-consumer >/dev/null + + log_info "Ensuring country services are up for ${COUNTRY} (ledger, saga)..." + country_compose --profile country up -d --build ledger-consumer status-saga >/dev/null + + for c in yape-kafka yape-payment-api yape-outbox-relay yape-fraud-consumer; do + if ! docker ps --format '{{.Names}}' | grep -Fxq "$c"; then + log_err "Required container is not running: ${c}" + exit 1 + fi + done + + if ! country_compose ps --status running --services | grep -Fxq 'status-saga'; then + log_err "status-saga is not running for ${COUNTRY}." + exit 1 + fi + + if [[ "$SCENARIO" == "success" ]] && ! country_compose ps --status running --services | grep -Fxq 'ledger-consumer'; then + log_err "ledger-consumer is not running for ${COUNTRY}." + exit 1 + fi + + log_ok "Base stack is ready." +} + +stop_ledger_consumer() { + log_warn "Stopping ledger-consumer for country ${COUNTRY} to simulate failure..." + country_compose stop ledger-consumer >/dev/null + LEDGER_STOPPED=1 + + if country_compose ps --status running --services | grep -Fxq 'ledger-consumer'; then + log_err "ledger-consumer is still running after stop command." + exit 1 + fi + + log_ok "ledger-consumer stopped." +} + +start_ledger_consumer() { + log_info "Starting ledger-consumer again for country ${COUNTRY}..." + country_compose up -d ledger-consumer >/dev/null + LEDGER_STOPPED=0 + log_ok "ledger-consumer restored." +} + +topic_name() { + local suffix="$1" + printf '%s.payments.%s' "$COUNTRY" "$suffix" +} + +start_topic_watchers() { + TMP_DIR="$(mktemp -d)" + + local topics=( + "$(topic_name payment.created.v1)" + "$(topic_name fraud.assessed.v1)" + "$(topic_name ledger.posted.v1)" + "$(topic_name payment.settled.v1)" + "$(topic_name payment.failed.v1)" + "$(topic_name payment.created.v1.dlt)" + "$(topic_name fraud.assessed.v1.dlt)" + "$(topic_name ledger.posted.v1.dlt)" + ) + + log_info "Starting Kafka watchers (timeout ${WATCH_TIMEOUT_MS}ms each topic)..." + + for topic in "${topics[@]}"; do + local safe + safe="${topic//./_}" + + ( + docker exec yape-kafka /opt/kafka/bin/kafka-console-consumer.sh \ + --bootstrap-server localhost:9092 \ + --topic "$topic" \ + --property print.timestamp=true \ + --property print.key=true \ + --timeout-ms "$WATCH_TIMEOUT_MS" \ + >"${TMP_DIR}/${safe}.out" \ + 2>"${TMP_DIR}/${safe}.err" || true + ) & + + echo "$topic" >>"${TMP_DIR}/topics.list" + done + + sleep 1 + log_ok "Watchers ready." +} + +create_payment() { + local cc payload response + cc="$(country_upper "$COUNTRY")" + + payload="{\"walletId\":\"${WALLET_ID}\",\"countryCode\":\"${cc}\",\"amount\":${AMOUNT},\"currency\":\"${CURRENCY}\"}" + + log_info "Creating payment via API (${API_URL}/payments)..." + response="$(curl -sS -X POST "${API_URL}/payments" -H 'Content-Type: application/json' -d "$payload")" + + PAYMENT_ID="$(printf '%s' "$response" | json_get_field paymentId)" + local status + status="$(printf '%s' "$response" | json_get_field status)" + + if [[ -z "$PAYMENT_ID" ]]; then + log_err "Failed to create payment. Raw response: $response" + exit 1 + fi + + log_ok "Payment created: paymentId=${PAYMENT_ID}, status=${status:-unknown}" +} + +read_status() { + curl -sS "${API_URL}/payments/${PAYMENT_ID}/status" +} + +poll_status_until_final() { + local started now elapsed status status_json + started="$(date +%s)" + + while true; do + status_json="$(read_status || true)" + status="$(printf '%s' "$status_json" | json_get_field status)" + + if [[ -n "$status" ]]; then + printf "%b[status] %s%b\n" "$C_DIM" "$status_json" "$C_RESET" + else + log_warn "Status endpoint not ready yet." + fi + + if [[ "$status" == "settled" || "$status" == "failed" ]]; then + log_ok "Final status reached: ${status}" + return 0 + fi + + now="$(date +%s)" + elapsed=$((now - started)) + + if [[ "$elapsed" -ge "$TIMEOUT_SECONDS" ]]; then + log_warn "Timeout waiting final status after ${TIMEOUT_SECONDS}s." + return 1 + fi + + sleep 2 + done +} + +wait_watchers() { + # Wait slightly longer than poll loop to allow delayed consumers + local wait_seconds=$((TIMEOUT_SECONDS + FAILURE_HOLD_SECONDS + 10)) + local i + for ((i=0; i/dev/null 2>&1; then + break + fi + sleep 1 + done +} + +print_topic_results() { + log_info "Kafka events captured for paymentId=${PAYMENT_ID}:" + + while IFS= read -r topic; do + [[ -z "$topic" ]] && continue + local safe out err line + safe="${topic//./_}" + out="${TMP_DIR}/${safe}.out" + err="${TMP_DIR}/${safe}.err" + + if [[ -s "$out" ]]; then + line="$(grep -F "$PAYMENT_ID" "$out" | head -n 1 || true)" + + if [[ -n "$line" ]]; then + printf "%b ✓ %s%b\n" "$C_OK" "$topic" "$C_RESET" + echo " $line" + else + printf "%b ~ %s%b\n" "$C_WARN" "$topic (messages seen, none matched paymentId)" "$C_RESET" + fi + else + printf "%b - %s%b\n" "$C_DIM" "$topic (no message in window)" "$C_RESET" + fi + + if [[ -s "$err" ]]; then + local filtered + filtered="$(grep -vE 'Processed a total of 0 messages|Reached end of topic|^$' "$err" || true)" + if [[ -n "$filtered" ]]; then + printf "%b note: %s%b\n" "$C_DIM" "$filtered" "$C_RESET" + fi + fi + done <"${TMP_DIR}/topics.list" +} + +run_success() { + start_topic_watchers + create_payment + poll_status_until_final || true + wait_watchers +} + +run_failure_stop_ledger() { + stop_ledger_consumer + start_topic_watchers + create_payment + + log_warn "Holding ledger down for ${FAILURE_HOLD_SECONDS}s..." + sleep "$FAILURE_HOLD_SECONDS" + + local snapshot + snapshot="$(read_status || true)" + if [[ -n "$snapshot" ]]; then + printf "%b[status-mid] %s%b\n" "$C_WARN" "$snapshot" "$C_RESET" + fi + + start_ledger_consumer + poll_status_until_final || true + wait_watchers +} + +print_summary() { + echo "" + echo "=== Scenario summary ===" + echo "Scenario : ${SCENARIO}" + echo "Country : ${COUNTRY}" + echo "Payment ID : ${PAYMENT_ID}" + echo "API : ${API_URL}" + echo "Kafka UI : http://localhost:8080" + echo "" + echo "Useful logs:" + echo " docker compose logs -f outbox-relay fraud-consumer payment-api" + echo " docker compose -p yape-${COUNTRY} -f docker-compose.country.yml --env-file .env --env-file .env.${COUNTRY} logs -f ledger-consumer status-saga" +} + +main() { + parse_args "$@" + + require_cmd docker + require_cmd curl + require_cmd node + + ensure_files + ensure_stack + + log_info "Running scenario '${SCENARIO}' for country '${COUNTRY}'..." + + case "$SCENARIO" in + success) + run_success + ;; + failure-stop-ledger) + run_failure_stop_ledger + ;; + esac + + print_topic_results + print_summary + log_ok "Done." +} + +main "$@" diff --git a/scripts/run-test.sh b/scripts/run-test.sh new file mode 100755 index 00000000..b10b7a22 --- /dev/null +++ b/scripts/run-test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +DOCKER_IMAGE="${DOCKER_TEST_IMAGE:-node:20-alpine}" + +log() { + printf '[test-docker] %s\n' "$*" +} + +error() { + printf '[test-docker][error] %s\n' "$*" >&2 +} + +require_docker() { + if ! command -v docker >/dev/null 2>&1; then + error "docker no está disponible en PATH." + exit 1 + fi +} + +main() { + require_docker + + log "Running tests inside Docker image: ${DOCKER_IMAGE}" + docker run --rm -t \ + -v "${ROOT_DIR}:/app" \ + -w /app \ + "${DOCKER_IMAGE}" \ + sh -lc "npm ci && npm test" +} + +main "$@" diff --git a/src/apps/fraud-consumer/main.ts b/src/apps/fraud-consumer/main.ts new file mode 100644 index 00000000..97f28db4 --- /dev/null +++ b/src/apps/fraud-consumer/main.ts @@ -0,0 +1,36 @@ +import 'reflect-metadata'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { EventTypes } from 'src/domain/events/event-types'; +import { FraudConsumerService } from 'src/domain/services/fraud-consumer.service'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; +import { KafkaConsumerRunner } from 'src/infrastructure/messaging/kafka-consumer-runner'; +import { FraudConsumerModule } from 'src/modules/consumers/fraud/fraud-consumer.module'; + +async function bootstrap(): Promise { + const logger = new Logger('FraudConsumerProcess'); + const app = await NestFactory.createApplicationContext(FraudConsumerModule); + const configService = app.get(ConfigService); + const runner = app.get(KafkaConsumerRunner); + const service = app.get(FraudConsumerService); + const topicResolver = app.get(TopicResolver); + + const countries = configService.getOrThrow('supportedCountries'); + const topics = Array.from( + new Set(countries.map((country) => topicResolver.resolve(EventTypes.PaymentCreatedV1, country))), + ); + const groupIdPrefix = configService.getOrThrow('kafkaGroupIdPrefix'); + const groupId = `${groupIdPrefix}.fraud-consumer.global`; + + await runner.run({ + consumerName: 'fraud-consumer', + groupId, + topics, + onMessage: (rawEvent) => service.handlePaymentCreated(rawEvent), + }); + + logger.log(`Fraud consumer (global) is running for countries: ${countries.join(', ')}`); +} + +void bootstrap(); diff --git a/src/apps/ledger-consumer/main.ts b/src/apps/ledger-consumer/main.ts new file mode 100644 index 00000000..576f33ac --- /dev/null +++ b/src/apps/ledger-consumer/main.ts @@ -0,0 +1,41 @@ +import 'reflect-metadata'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { EventTypes } from 'src/domain/events/event-types'; +import { LedgerConsumerService } from 'src/domain/services/ledger-consumer.service'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; +import { KafkaConsumerRunner } from 'src/infrastructure/messaging/kafka-consumer-runner'; +import { LedgerConsumerModule } from 'src/modules/consumers/ledger/ledger-consumer.module'; + +async function bootstrap(): Promise { + const logger = new Logger('LedgerConsumerProcess'); + const app = await NestFactory.createApplicationContext(LedgerConsumerModule); + const configService = app.get(ConfigService); + const runner = app.get(KafkaConsumerRunner); + const service = app.get(LedgerConsumerService); + const topicResolver = app.get(TopicResolver); + const supportedCountries = configService.getOrThrow('supportedCountries'); + + const country = (process.env.CONSUMER_COUNTRY ?? '').toLowerCase(); + if (!country || !supportedCountries.includes(country)) { + throw new Error( + `CONSUMER_COUNTRY must be one of [${supportedCountries.join(', ')}] for ledger-consumer process.`, + ); + } + + const topic = topicResolver.resolve(EventTypes.PaymentCreatedV1, country); + const groupIdPrefix = configService.getOrThrow('kafkaGroupIdPrefix'); + const groupId = `${groupIdPrefix}.ledger-consumer.${country}`; + + await runner.run({ + consumerName: 'ledger-consumer', + groupId, + topics: [topic], + onMessage: (rawEvent) => service.handlePaymentCreated(rawEvent), + }); + + logger.log(`Ledger consumer is running for country ${country} on topic ${topic}`); +} + +void bootstrap(); diff --git a/src/apps/outbox-relay/main.ts b/src/apps/outbox-relay/main.ts new file mode 100644 index 00000000..62bffd28 --- /dev/null +++ b/src/apps/outbox-relay/main.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { Logger } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { OutboxRelayService } from 'src/domain/services/outbox-relay.service'; +import { OutboxRelayModule } from 'src/modules/outbox/outbox-relay.module'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function bootstrap(): Promise { + const logger = new Logger('OutboxRelayProcess'); + const app = await NestFactory.createApplicationContext(OutboxRelayModule); + const config = app.get(ConfigService); + const relay = app.get(OutboxRelayService); + + const pollMs = config.getOrThrow('relayPollMs'); + logger.log(`Outbox relay started with polling interval ${pollMs}ms`); + + while (true) { + try { + await relay.relayBatch(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + logger.error(`Outbox relay batch failed: ${message}`, stack); + } + await sleep(pollMs); + } +} + +void bootstrap(); diff --git a/src/apps/payment-api/main.ts b/src/apps/payment-api/main.ts new file mode 100644 index 00000000..290b3c9f --- /dev/null +++ b/src/apps/payment-api/main.ts @@ -0,0 +1,23 @@ +import 'reflect-metadata'; +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { PaymentApiModule } from 'src/modules/payment/payment-api.module'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(PaymentApiModule); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidUnknownValues: true, + }), + ); + + const config = app.get(ConfigService); + const port = config.getOrThrow('port'); + + await app.listen(port); +} + +void bootstrap(); diff --git a/src/apps/status-saga/main.ts b/src/apps/status-saga/main.ts new file mode 100644 index 00000000..776d34e9 --- /dev/null +++ b/src/apps/status-saga/main.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { EventTypes } from 'src/domain/events/event-types'; +import { StatusSagaService } from 'src/domain/services/status-saga.service'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; +import { KafkaConsumerRunner } from 'src/infrastructure/messaging/kafka-consumer-runner'; +import { StatusSagaModule } from 'src/modules/status-saga/status-saga.module'; + +async function bootstrap(): Promise { + const logger = new Logger('StatusSagaProcess'); + const app = await NestFactory.createApplicationContext(StatusSagaModule); + const configService = app.get(ConfigService); + const runner = app.get(KafkaConsumerRunner); + const service = app.get(StatusSagaService); + const topicResolver = app.get(TopicResolver); + const supportedCountries = configService.getOrThrow('supportedCountries'); + + const country = (process.env.CONSUMER_COUNTRY ?? '').toLowerCase(); + if (!country || !supportedCountries.includes(country)) { + throw new Error(`CONSUMER_COUNTRY must be one of [${supportedCountries.join(', ')}] for status-saga process.`); + } + + const fraudTopic = topicResolver.resolve(EventTypes.FraudAssessedV1, country); + const ledgerTopic = topicResolver.resolve(EventTypes.LedgerPostedV1, country); + const groupIdPrefix = configService.getOrThrow('kafkaGroupIdPrefix'); + const groupId = `${groupIdPrefix}.status-saga.${country}`; + + await runner.run({ + consumerName: 'status-saga', + groupId, + topics: [fraudTopic, ledgerTopic], + onMessage: (rawEvent, sourceTopic) => { + if (sourceTopic === fraudTopic) { + return service.onFraudAssessed(rawEvent); + } + + return service.onLedgerPosted(rawEvent); + }, + }); + + logger.log(`Status saga is running for country ${country} on topics ${fraudTopic} and ${ledgerTopic}`); +} + +void bootstrap(); diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 00000000..89b8f435 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,41 @@ +export interface AppEnv { + nodeEnv: string; + port: number; + dbHost: string; + dbPort: number; + dbUser: string; + dbPassword: string; + dbName: string; + kafkaBrokers: string[]; + kafkaClientId: string; + kafkaGroupIdPrefix: string; + supportedCountries: string[]; + relayPollMs: number; + maxConsumerRetries: number; + countryNamespaceEnabled: boolean; +} + +const parseCountries = (raw: string): string[] => + raw + .split(',') + .map((country) => country.trim().toLowerCase()) + .filter((country) => country.length > 0); + +export function loadEnv(): AppEnv { + return { + nodeEnv: process.env.NODE_ENV ?? 'development', + port: Number(process.env.PORT ?? 3000), + dbHost: process.env.DB_HOST ?? '127.0.0.1', + dbPort: Number(process.env.DB_PORT ?? 5432), + dbUser: process.env.DB_USER ?? 'postgres', + dbPassword: process.env.DB_PASSWORD ?? 'postgres', + dbName: process.env.DB_NAME ?? 'yape', + kafkaBrokers: (process.env.KAFKA_BROKERS ?? '127.0.0.1:9092').split(','), + kafkaClientId: process.env.KAFKA_CLIENT_ID ?? 'payment-platform', + kafkaGroupIdPrefix: process.env.KAFKA_GROUP_PREFIX ?? 'challenge', + supportedCountries: parseCountries(process.env.SUPPORTED_COUNTRIES ?? 'pe,mx'), + relayPollMs: Number(process.env.RELAY_POLL_MS ?? 1000), + maxConsumerRetries: Number(process.env.MAX_CONSUMER_RETRIES ?? 3), + countryNamespaceEnabled: (process.env.COUNTRY_NAMESPACE_ENABLED ?? 'false') === 'true', + }; +} diff --git a/src/domain/entities/outbox-event.ts b/src/domain/entities/outbox-event.ts new file mode 100644 index 00000000..2b86c37f --- /dev/null +++ b/src/domain/entities/outbox-event.ts @@ -0,0 +1,22 @@ +export enum OutboxStatus { + Pending = 'pending', + Processing = 'processing', + Published = 'published', + Failed = 'failed', +} + +export interface OutboxEvent { + id: string; + aggregateId: string; + eventId: string; + eventType: string; + topic: string; + countryCode: string; + payload: string; + status: OutboxStatus; + attempts: number; + nextAttemptAt: Date; + publishedAt?: Date | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/domain/entities/payment-step.ts b/src/domain/entities/payment-step.ts new file mode 100644 index 00000000..59860e98 --- /dev/null +++ b/src/domain/entities/payment-step.ts @@ -0,0 +1,13 @@ +export enum StepStatus { + Pending = 'pending', + Succeeded = 'succeeded', + Failed = 'failed', +} + +export interface PaymentStep { + paymentId: string; + fraudStatus: StepStatus; + ledgerStatus: StepStatus; + failureReason?: string | null; + updatedAt: Date; +} diff --git a/src/domain/entities/payment.ts b/src/domain/entities/payment.ts new file mode 100644 index 00000000..fa925049 --- /dev/null +++ b/src/domain/entities/payment.ts @@ -0,0 +1,16 @@ +export enum PaymentStatus { + Pending = 'pending', + Settled = 'settled', + Failed = 'failed', +} + +export interface Payment { + id: string; + walletId: string; + countryCode: string; + amount: number; + currency: string; + status: PaymentStatus; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/domain/events/event-types.ts b/src/domain/events/event-types.ts new file mode 100644 index 00000000..49ed7bf2 --- /dev/null +++ b/src/domain/events/event-types.ts @@ -0,0 +1,51 @@ +export const EventTypes = { + PaymentCreatedV1: 'payment.created.v1', + FraudAssessedV1: 'fraud.assessed.v1', + LedgerPostedV1: 'ledger.posted.v1', + PaymentSettledV1: 'payment.settled.v1', + PaymentFailedV1: 'payment.failed.v1', +} as const; + +export type EventType = (typeof EventTypes)[keyof typeof EventTypes]; + +export interface DomainEvent { + eventId: string; + type: EventType; + aggregateId: string; + occurredAt: string; + countryCode: string; + payload: TPayload; + schemaVersion: 1; +} + +export interface PaymentCreatedPayload { + paymentId: string; + walletId: string; + amount: number; + currency: string; +} + +export interface FraudAssessedPayload { + paymentId: string; + approved: boolean; + reason?: string; +} + +export interface LedgerPostedPayload { + paymentId: string; + entryId: string; + success: boolean; + reason?: string; +} + +export interface PaymentSettledPayload { + paymentId: string; + settledAt: string; +} + +export interface PaymentFailedPayload { + paymentId: string; + failedAt: string; + reason: string; + sourceEventId: string; +} diff --git a/src/domain/repositories/outbox.repository.ts b/src/domain/repositories/outbox.repository.ts new file mode 100644 index 00000000..7c907bfe --- /dev/null +++ b/src/domain/repositories/outbox.repository.ts @@ -0,0 +1,20 @@ +import { OutboxEvent } from 'src/domain/entities/outbox-event'; + +export const OUTBOX_REPOSITORY = Symbol('OUTBOX_REPOSITORY'); + +export interface EnqueueOutboxEventParams { + id: string; + aggregateId: string; + eventId: string; + eventType: string; + topic: string; + countryCode: string; + payload: string; +} + +export interface OutboxRepository { + enqueue(params: EnqueueOutboxEventParams): Promise; + lockPendingBatch(limit: number, now: Date): Promise; + markPublished(outboxId: string, publishedAt: Date): Promise; + markForRetry(outboxId: string, nextAttemptAt: Date): Promise; +} diff --git a/src/domain/repositories/payment-step.repository.ts b/src/domain/repositories/payment-step.repository.ts new file mode 100644 index 00000000..af98520f --- /dev/null +++ b/src/domain/repositories/payment-step.repository.ts @@ -0,0 +1,10 @@ +import { PaymentStep, StepStatus } from 'src/domain/entities/payment-step'; + +export const PAYMENT_STEP_REPOSITORY = Symbol('PAYMENT_STEP_REPOSITORY'); + +export interface PaymentStepRepository { + init(paymentId: string): Promise; + markFraud(paymentId: string, status: StepStatus, reason?: string): Promise; + markLedger(paymentId: string, status: StepStatus, reason?: string): Promise; + getByPaymentId(paymentId: string): Promise; +} diff --git a/src/domain/repositories/payment.repository.ts b/src/domain/repositories/payment.repository.ts new file mode 100644 index 00000000..c3db24c6 --- /dev/null +++ b/src/domain/repositories/payment.repository.ts @@ -0,0 +1,17 @@ +import { Payment, PaymentStatus } from 'src/domain/entities/payment'; + +export interface CreatePaymentParams { + id: string; + walletId: string; + countryCode: string; + amount: number; + currency: string; +} + +export const PAYMENT_REPOSITORY = Symbol('PAYMENT_REPOSITORY'); + +export interface PaymentRepository { + createPending(params: CreatePaymentParams): Promise; + updateStatus(paymentId: string, status: PaymentStatus): Promise; + getById(paymentId: string): Promise; +} diff --git a/src/domain/repositories/ports.ts b/src/domain/repositories/ports.ts new file mode 100644 index 00000000..19d1b18c --- /dev/null +++ b/src/domain/repositories/ports.ts @@ -0,0 +1,15 @@ +export const EVENT_PUBLISHER = Symbol('EVENT_PUBLISHER'); +export const DLT_PUBLISHER = Symbol('DLT_PUBLISHER'); + +export interface EventPublisher { + publish(topic: string, payload: string, key?: string): Promise; +} + +export interface DeadLetterPublisher { + publishDeadLetter( + originalTopic: string, + payload: string, + reason: string, + sourceEventId: string, + ): Promise; +} diff --git a/src/domain/repositories/processed-event.repository.ts b/src/domain/repositories/processed-event.repository.ts new file mode 100644 index 00000000..9a9625a4 --- /dev/null +++ b/src/domain/repositories/processed-event.repository.ts @@ -0,0 +1,5 @@ +export const PROCESSED_EVENT_REPOSITORY = Symbol('PROCESSED_EVENT_REPOSITORY'); + +export interface ProcessedEventRepository { + tryMarkProcessed(consumerName: string, countryCode: string, eventId: string): Promise; +} diff --git a/src/domain/repositories/unit-of-work.ts b/src/domain/repositories/unit-of-work.ts new file mode 100644 index 00000000..8771342c --- /dev/null +++ b/src/domain/repositories/unit-of-work.ts @@ -0,0 +1,5 @@ +export const UNIT_OF_WORK = Symbol('UNIT_OF_WORK'); + +export interface UnitOfWork { + execute(work: () => Promise): Promise; +} diff --git a/src/domain/services/consumer-executor.service.ts b/src/domain/services/consumer-executor.service.ts new file mode 100644 index 00000000..32228373 --- /dev/null +++ b/src/domain/services/consumer-executor.service.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { DLT_PUBLISHER, DeadLetterPublisher } from 'src/domain/repositories/ports'; + +export interface ConsumerExecutionContext { + consumerName: string; + sourceTopic: string; + eventId: string; + payload: string; +} + +@Injectable() +export class ConsumerExecutorService { + private readonly logger = new Logger(ConsumerExecutorService.name); + + constructor( + @Inject(DLT_PUBLISHER) + private readonly deadLetterPublisher: DeadLetterPublisher, + ) {} + + async executeWithRetry( + context: ConsumerExecutionContext, + maxRetries: number, + operation: () => Promise, + ): Promise { + let attempt = 0; + + while (attempt < maxRetries) { + try { + await operation(); + return; + } catch (error) { + attempt += 1; + + if (attempt >= maxRetries) { + const reason = error instanceof Error ? error.message : 'unknown_error'; + + await this.deadLetterPublisher.publishDeadLetter( + context.sourceTopic, + context.payload, + reason, + context.eventId, + ); + + this.logger.error( + `Consumer ${context.consumerName} exhausted retries for ${context.eventId}. Sent to DLT.`, + ); + return; + } + } + } + } +} diff --git a/src/domain/services/fraud-consumer.service.ts b/src/domain/services/fraud-consumer.service.ts new file mode 100644 index 00000000..fe507188 --- /dev/null +++ b/src/domain/services/fraud-consumer.service.ts @@ -0,0 +1,64 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EventTypes, FraudAssessedPayload, PaymentCreatedPayload } from 'src/domain/events/event-types'; +import { EVENT_PUBLISHER, EventPublisher } from 'src/domain/repositories/ports'; +import { + PROCESSED_EVENT_REPOSITORY, + ProcessedEventRepository, +} from 'src/domain/repositories/processed-event.repository'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; + +interface PaymentCreatedEvent { + eventId: string; + aggregateId: string; + countryCode: string; + payload: PaymentCreatedPayload; +} + +@Injectable() +export class FraudConsumerService { + private readonly consumerName = 'fraud-consumer'; + + constructor( + @Inject(PROCESSED_EVENT_REPOSITORY) + private readonly processedEventRepository: ProcessedEventRepository, + @Inject(EVENT_PUBLISHER) + private readonly eventPublisher: EventPublisher, + private readonly topicResolver: TopicResolver, + ) {} + + async handlePaymentCreated(rawEvent: string): Promise { + const event = JSON.parse(rawEvent) as PaymentCreatedEvent; + + const firstTime = await this.processedEventRepository.tryMarkProcessed( + this.consumerName, + event.countryCode, + event.eventId, + ); + if (!firstTime) { + return; + } + + const approved = event.payload.amount <= 1000; + const result: FraudAssessedPayload = { + paymentId: event.payload.paymentId, + approved, + reason: approved ? undefined : 'risk_threshold_exceeded', + }; + + const outputTopic = this.topicResolver.resolve(EventTypes.FraudAssessedV1, event.countryCode); + + await this.eventPublisher.publish( + outputTopic, + JSON.stringify({ + eventId: `${event.eventId}.fraud`, + type: EventTypes.FraudAssessedV1, + aggregateId: event.aggregateId, + occurredAt: new Date().toISOString(), + countryCode: event.countryCode, + schemaVersion: 1, + payload: result, + }), + event.aggregateId, + ); + } +} diff --git a/src/domain/services/ledger-consumer.service.ts b/src/domain/services/ledger-consumer.service.ts new file mode 100644 index 00000000..1c1b49b2 --- /dev/null +++ b/src/domain/services/ledger-consumer.service.ts @@ -0,0 +1,64 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { EventTypes, LedgerPostedPayload, PaymentCreatedPayload } from 'src/domain/events/event-types'; +import { EVENT_PUBLISHER, EventPublisher } from 'src/domain/repositories/ports'; +import { + PROCESSED_EVENT_REPOSITORY, + ProcessedEventRepository, +} from 'src/domain/repositories/processed-event.repository'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; + +interface PaymentCreatedEvent { + eventId: string; + aggregateId: string; + countryCode: string; + payload: PaymentCreatedPayload; +} + +@Injectable() +export class LedgerConsumerService { + private readonly consumerName = 'ledger-consumer'; + + constructor( + @Inject(PROCESSED_EVENT_REPOSITORY) + private readonly processedEventRepository: ProcessedEventRepository, + @Inject(EVENT_PUBLISHER) + private readonly eventPublisher: EventPublisher, + private readonly topicResolver: TopicResolver, + ) {} + + async handlePaymentCreated(rawEvent: string): Promise { + const event = JSON.parse(rawEvent) as PaymentCreatedEvent; + + const firstTime = await this.processedEventRepository.tryMarkProcessed( + this.consumerName, + event.countryCode, + event.eventId, + ); + if (!firstTime) { + return; + } + + const result: LedgerPostedPayload = { + paymentId: event.payload.paymentId, + entryId: uuidv4(), + success: true, + }; + + const outputTopic = this.topicResolver.resolve(EventTypes.LedgerPostedV1, event.countryCode); + + await this.eventPublisher.publish( + outputTopic, + JSON.stringify({ + eventId: `${event.eventId}.ledger`, + type: EventTypes.LedgerPostedV1, + aggregateId: event.aggregateId, + occurredAt: new Date().toISOString(), + countryCode: event.countryCode, + schemaVersion: 1, + payload: result, + }), + event.aggregateId, + ); + } +} diff --git a/src/domain/services/outbox-relay.service.ts b/src/domain/services/outbox-relay.service.ts new file mode 100644 index 00000000..3c603939 --- /dev/null +++ b/src/domain/services/outbox-relay.service.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { OUTBOX_REPOSITORY, OutboxRepository } from 'src/domain/repositories/outbox.repository'; +import { EVENT_PUBLISHER, EventPublisher } from 'src/domain/repositories/ports'; + +@Injectable() +export class OutboxRelayService { + private readonly logger = new Logger(OutboxRelayService.name); + + constructor( + @Inject(OUTBOX_REPOSITORY) + private readonly outboxRepository: OutboxRepository, + @Inject(EVENT_PUBLISHER) + private readonly eventPublisher: EventPublisher, + ) {} + + async relayBatch(batchSize = 50): Promise { + const pending = await this.outboxRepository.lockPendingBatch(batchSize, new Date()); + + for (const event of pending) { + try { + await this.eventPublisher.publish(event.topic, event.payload, event.aggregateId); + await this.outboxRepository.markPublished(event.id, new Date()); + } catch (error) { + const retryAt = new Date(Date.now() + Math.min((event.attempts + 1) * 1000, 30000)); + await this.outboxRepository.markForRetry(event.id, retryAt); + this.logger.warn( + `Failed to publish outbox event ${event.id}. It will be retried at ${retryAt.toISOString()}.`, + ); + } + } + + return pending.length; + } +} diff --git a/src/domain/services/payment.service.ts b/src/domain/services/payment.service.ts new file mode 100644 index 00000000..90904664 --- /dev/null +++ b/src/domain/services/payment.service.ts @@ -0,0 +1,85 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { PaymentStatus } from 'src/domain/entities/payment'; +import { EventTypes } from 'src/domain/events/event-types'; +import { OUTBOX_REPOSITORY, OutboxRepository } from 'src/domain/repositories/outbox.repository'; +import { PAYMENT_REPOSITORY, PaymentRepository } from 'src/domain/repositories/payment.repository'; +import { PAYMENT_STEP_REPOSITORY, PaymentStepRepository } from 'src/domain/repositories/payment-step.repository'; +import { UNIT_OF_WORK, UnitOfWork } from 'src/domain/repositories/unit-of-work'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; + +export interface CreatePaymentCommand { + walletId: string; + countryCode: string; + amount: number; + currency: string; +} + +export interface PaymentCreationResult { + paymentId: string; + status: PaymentStatus; +} + +@Injectable() +export class PaymentService { + constructor( + @Inject(UNIT_OF_WORK) + private readonly unitOfWork: UnitOfWork, + @Inject(PAYMENT_REPOSITORY) + private readonly paymentRepository: PaymentRepository, + @Inject(PAYMENT_STEP_REPOSITORY) + private readonly paymentStepRepository: PaymentStepRepository, + @Inject(OUTBOX_REPOSITORY) + private readonly outboxRepository: OutboxRepository, + private readonly topicResolver: TopicResolver, + ) {} + + async createPayment(command: CreatePaymentCommand): Promise { + const paymentId = uuidv4(); + const eventId = uuidv4(); + const outboxId = uuidv4(); + const occurredAt = new Date().toISOString(); + + await this.unitOfWork.execute(async () => { + await this.paymentRepository.createPending({ + id: paymentId, + walletId: command.walletId, + countryCode: command.countryCode, + amount: command.amount, + currency: command.currency, + }); + + await this.paymentStepRepository.init(paymentId); + + const topic = this.topicResolver.resolve(EventTypes.PaymentCreatedV1, command.countryCode); + + await this.outboxRepository.enqueue({ + id: outboxId, + aggregateId: paymentId, + eventId, + eventType: EventTypes.PaymentCreatedV1, + topic, + countryCode: command.countryCode, + payload: JSON.stringify({ + eventId, + type: EventTypes.PaymentCreatedV1, + aggregateId: paymentId, + occurredAt, + countryCode: command.countryCode, + schemaVersion: 1, + payload: { + paymentId, + walletId: command.walletId, + amount: command.amount, + currency: command.currency, + }, + }), + }); + }); + + return { + paymentId, + status: PaymentStatus.Pending, + }; + } +} diff --git a/src/domain/services/status-saga.service.ts b/src/domain/services/status-saga.service.ts new file mode 100644 index 00000000..1acd4d19 --- /dev/null +++ b/src/domain/services/status-saga.service.ts @@ -0,0 +1,140 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { PaymentStatus } from 'src/domain/entities/payment'; +import { StepStatus } from 'src/domain/entities/payment-step'; +import { + EventTypes, + FraudAssessedPayload, + LedgerPostedPayload, + PaymentFailedPayload, + PaymentSettledPayload, +} from 'src/domain/events/event-types'; +import { PAYMENT_REPOSITORY, PaymentRepository } from 'src/domain/repositories/payment.repository'; +import { PAYMENT_STEP_REPOSITORY, PaymentStepRepository } from 'src/domain/repositories/payment-step.repository'; +import { EVENT_PUBLISHER, EventPublisher } from 'src/domain/repositories/ports'; +import { + PROCESSED_EVENT_REPOSITORY, + ProcessedEventRepository, +} from 'src/domain/repositories/processed-event.repository'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; + +interface IntegrationEvent { + eventId: string; + aggregateId: string; + countryCode: string; + payload: TPayload; +} + +@Injectable() +export class StatusSagaService { + private readonly consumerName = 'status-saga'; + + constructor( + @Inject(PROCESSED_EVENT_REPOSITORY) + private readonly processedEventRepository: ProcessedEventRepository, + @Inject(PAYMENT_REPOSITORY) + private readonly paymentRepository: PaymentRepository, + @Inject(PAYMENT_STEP_REPOSITORY) + private readonly paymentStepRepository: PaymentStepRepository, + @Inject(EVENT_PUBLISHER) + private readonly eventPublisher: EventPublisher, + private readonly topicResolver: TopicResolver, + ) {} + + async onFraudAssessed(rawEvent: string): Promise { + const event = JSON.parse(rawEvent) as IntegrationEvent; + + const firstTime = await this.processedEventRepository.tryMarkProcessed( + this.consumerName, + event.countryCode, + event.eventId, + ); + if (!firstTime) { + return; + } + + await this.paymentStepRepository.markFraud( + event.payload.paymentId, + event.payload.approved ? StepStatus.Succeeded : StepStatus.Failed, + event.payload.reason, + ); + + await this.reconcile(event.aggregateId, event.countryCode, event.eventId); + } + + async onLedgerPosted(rawEvent: string): Promise { + const event = JSON.parse(rawEvent) as IntegrationEvent; + + const firstTime = await this.processedEventRepository.tryMarkProcessed( + this.consumerName, + event.countryCode, + event.eventId, + ); + if (!firstTime) { + return; + } + + await this.paymentStepRepository.markLedger( + event.payload.paymentId, + event.payload.success ? StepStatus.Succeeded : StepStatus.Failed, + event.payload.reason, + ); + + await this.reconcile(event.aggregateId, event.countryCode, event.eventId); + } + + private async reconcile(paymentId: string, countryCode: string, sourceEventId: string): Promise { + const step = await this.paymentStepRepository.getByPaymentId(paymentId); + if (!step) { + return; + } + + if (step.fraudStatus === StepStatus.Failed || step.ledgerStatus === StepStatus.Failed) { + await this.paymentRepository.updateStatus(paymentId, PaymentStatus.Failed); + + const payload: PaymentFailedPayload = { + paymentId, + failedAt: new Date().toISOString(), + reason: step.failureReason ?? 'consumer_failure', + sourceEventId, + }; + + await this.eventPublisher.publish( + this.topicResolver.resolve(EventTypes.PaymentFailedV1, countryCode), + JSON.stringify({ + eventId: `${sourceEventId}.payment_failed`, + type: EventTypes.PaymentFailedV1, + aggregateId: paymentId, + occurredAt: new Date().toISOString(), + countryCode, + schemaVersion: 1, + payload, + }), + paymentId, + ); + return; + } + + if (step.fraudStatus === StepStatus.Succeeded && step.ledgerStatus === StepStatus.Succeeded) { + await this.paymentRepository.updateStatus(paymentId, PaymentStatus.Settled); + + const payload: PaymentSettledPayload = { + paymentId, + settledAt: new Date().toISOString(), + }; + + await this.eventPublisher.publish( + this.topicResolver.resolve(EventTypes.PaymentSettledV1, countryCode), + JSON.stringify({ + eventId: `${sourceEventId}.payment_settled`, + type: EventTypes.PaymentSettledV1, + aggregateId: paymentId, + occurredAt: new Date().toISOString(), + countryCode, + schemaVersion: 1, + payload, + }), + paymentId, + ); + } + } +} diff --git a/src/domain/services/topic-resolver.ts b/src/domain/services/topic-resolver.ts new file mode 100644 index 00000000..b9e72ada --- /dev/null +++ b/src/domain/services/topic-resolver.ts @@ -0,0 +1,11 @@ +export class TopicResolver { + constructor(private readonly perCountryNamespaceEnabled = false) {} + + resolve(baseTopic: string, countryCode: string): string { + if (!this.perCountryNamespaceEnabled) { + return baseTopic; + } + + return `${countryCode.toLowerCase()}.payments.${baseTopic}`; + } +} diff --git a/src/infrastructure/messaging/kafka-consumer-runner.ts b/src/infrastructure/messaging/kafka-consumer-runner.ts new file mode 100644 index 00000000..898f0ed8 --- /dev/null +++ b/src/infrastructure/messaging/kafka-consumer-runner.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Consumer, Kafka } from 'kafkajs'; +import { ConsumerExecutorService } from 'src/domain/services/consumer-executor.service'; + +@Injectable() +export class KafkaConsumerRunner { + private readonly logger = new Logger(KafkaConsumerRunner.name); + private readonly kafka: Kafka; + + constructor( + private readonly configService: ConfigService, + private readonly consumerExecutorService: ConsumerExecutorService, + ) { + this.kafka = new Kafka({ + clientId: this.configService.getOrThrow('kafkaClientId'), + brokers: this.configService.getOrThrow('kafkaBrokers'), + }); + } + + async run(params: { + consumerName: string; + topics: string[]; + groupId: string; + onMessage: (rawEvent: string, sourceTopic: string) => Promise; + }): Promise { + const consumer = this.kafka.consumer({ groupId: params.groupId }); + await consumer.connect(); + for (const topic of params.topics) { + await consumer.subscribe({ topic, fromBeginning: true }); + } + + await consumer.run({ + eachMessage: async ({ topic, message }) => { + const rawEvent = (message.value ?? Buffer.from('')).toString('utf-8'); + if (!rawEvent) { + return; + } + + let eventId = 'unknown'; + try { + const parsed = JSON.parse(rawEvent) as { eventId?: string }; + eventId = parsed.eventId ?? eventId; + } catch { + eventId = 'malformed_event'; + } + + await this.consumerExecutorService.executeWithRetry( + { + consumerName: params.consumerName, + sourceTopic: topic, + eventId, + payload: rawEvent, + }, + this.configService.getOrThrow('maxConsumerRetries'), + () => params.onMessage(rawEvent, topic), + ); + }, + }); + + this.logger.log( + `Consumer ${params.consumerName} (${params.groupId}) subscribed to topics: ${params.topics.join(', ')}`, + ); + return consumer; + } +} diff --git a/src/infrastructure/messaging/kafka-publisher.ts b/src/infrastructure/messaging/kafka-publisher.ts new file mode 100644 index 00000000..2049ce57 --- /dev/null +++ b/src/infrastructure/messaging/kafka-publisher.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Kafka, Producer } from 'kafkajs'; +import { DeadLetterPublisher, EventPublisher } from 'src/domain/repositories/ports'; + +@Injectable() +export class KafkaPublisher implements EventPublisher, DeadLetterPublisher, OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(KafkaPublisher.name); + private producer!: Producer; + private kafka!: Kafka; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit(): Promise { + this.kafka = new Kafka({ + clientId: this.configService.getOrThrow('kafkaClientId'), + brokers: this.configService.getOrThrow('kafkaBrokers'), + }); + + this.producer = this.kafka.producer(); + await this.producer.connect(); + } + + async onModuleDestroy(): Promise { + if (this.producer) { + await this.producer.disconnect(); + } + } + + async publish(topic: string, payload: string, key?: string): Promise { + await this.producer.send({ + topic, + messages: [ + { + key, + value: payload, + }, + ], + }); + } + + async publishDeadLetter( + originalTopic: string, + payload: string, + reason: string, + sourceEventId: string, + ): Promise { + const dltTopic = `${originalTopic}.dlt`; + let parsedPayload: unknown; + + try { + parsedPayload = JSON.parse(payload); + } catch { + parsedPayload = { rawPayload: payload }; + } + + await this.producer.send({ + topic: dltTopic, + messages: [ + { + key: sourceEventId, + value: JSON.stringify({ + sourceTopic: originalTopic, + sourceEventId, + reason, + payload: parsedPayload, + occurredAt: new Date().toISOString(), + }), + }, + ], + }); + + this.logger.warn(`Message ${sourceEventId} moved to DLT ${dltTopic} because: ${reason}`); + } +} diff --git a/src/infrastructure/persistence/entities/outbox-event.orm-entity.ts b/src/infrastructure/persistence/entities/outbox-event.orm-entity.ts new file mode 100644 index 00000000..3d6514f5 --- /dev/null +++ b/src/infrastructure/persistence/entities/outbox-event.orm-entity.ts @@ -0,0 +1,44 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { OutboxStatus } from 'src/domain/entities/outbox-event'; + +@Entity({ name: 'outbox_events' }) +export class OutboxEventOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'uuid' }) + aggregateId!: string; + + @Column({ type: 'uuid', unique: true }) + eventId!: string; + + @Column({ type: 'varchar', length: 128 }) + eventType!: string; + + @Column({ type: 'varchar', length: 255 }) + topic!: string; + + @Column({ type: 'char', length: 2 }) + countryCode!: string; + + @Column({ type: 'text' }) + payload!: string; + + @Column({ type: 'varchar', length: 16 }) + status!: OutboxStatus; + + @Column({ type: 'int', default: 0 }) + attempts!: number; + + @Column({ type: 'timestamptz' }) + nextAttemptAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + publishedAt!: Date | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/infrastructure/persistence/entities/payment-step.orm-entity.ts b/src/infrastructure/persistence/entities/payment-step.orm-entity.ts new file mode 100644 index 00000000..363ae940 --- /dev/null +++ b/src/infrastructure/persistence/entities/payment-step.orm-entity.ts @@ -0,0 +1,24 @@ +import { Column, Entity, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { StepStatus } from 'src/domain/entities/payment-step'; +import { PaymentOrmEntity } from 'src/infrastructure/persistence/entities/payment.orm-entity'; + +@Entity({ name: 'payment_steps' }) +export class PaymentStepOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + paymentId!: string; + + @OneToOne(() => PaymentOrmEntity) + payment?: PaymentOrmEntity; + + @Column({ type: 'varchar', length: 16 }) + fraudStatus!: StepStatus; + + @Column({ type: 'varchar', length: 16 }) + ledgerStatus!: StepStatus; + + @Column({ type: 'varchar', length: 255, nullable: true }) + failureReason!: string | null; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/infrastructure/persistence/entities/payment.orm-entity.ts b/src/infrastructure/persistence/entities/payment.orm-entity.ts new file mode 100644 index 00000000..e1218b81 --- /dev/null +++ b/src/infrastructure/persistence/entities/payment.orm-entity.ts @@ -0,0 +1,29 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { PaymentStatus } from 'src/domain/entities/payment'; + +@Entity({ name: 'payments' }) +export class PaymentOrmEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ type: 'varchar', length: 64 }) + walletId!: string; + + @Column({ type: 'char', length: 2 }) + countryCode!: string; + + @Column({ type: 'numeric', precision: 14, scale: 2 }) + amount!: string; + + @Column({ type: 'varchar', length: 3 }) + currency!: string; + + @Column({ type: 'varchar', length: 16 }) + status!: PaymentStatus; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/infrastructure/persistence/entities/processed-event.orm-entity.ts b/src/infrastructure/persistence/entities/processed-event.orm-entity.ts new file mode 100644 index 00000000..79db8753 --- /dev/null +++ b/src/infrastructure/persistence/entities/processed-event.orm-entity.ts @@ -0,0 +1,20 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity({ name: 'processed_events' }) +@Index('uniq_consumer_country_event', ['consumerName', 'countryCode', 'eventId'], { unique: true }) +export class ProcessedEventOrmEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 128 }) + consumerName!: string; + + @Column({ type: 'char', length: 2 }) + countryCode!: string; + + @Column({ type: 'varchar', length: 128 }) + eventId!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + processedAt!: Date; +} diff --git a/src/infrastructure/persistence/repositories/outbox.typeorm-repository.ts b/src/infrastructure/persistence/repositories/outbox.typeorm-repository.ts new file mode 100644 index 00000000..f9267fc9 --- /dev/null +++ b/src/infrastructure/persistence/repositories/outbox.typeorm-repository.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@nestjs/common'; +import { Brackets, DataSource } from 'typeorm'; +import { OutboxStatus } from 'src/domain/entities/outbox-event'; +import { EnqueueOutboxEventParams, OutboxRepository } from 'src/domain/repositories/outbox.repository'; +import { OutboxEventOrmEntity } from 'src/infrastructure/persistence/entities/outbox-event.orm-entity'; +import { TransactionContext } from 'src/infrastructure/persistence/transaction-context'; + +@Injectable() +export class TypeOrmOutboxRepository implements OutboxRepository { + private static readonly CLAIM_TIMEOUT_MS = 60_000; + + constructor( + private readonly dataSource: DataSource, + private readonly transactionContext: TransactionContext, + ) {} + + async enqueue(params: EnqueueOutboxEventParams): Promise { + const manager = this.transactionContext.getEntityManager() ?? this.dataSource.manager; + const repository = manager.getRepository(OutboxEventOrmEntity); + await repository.save({ + id: params.id, + aggregateId: params.aggregateId, + eventId: params.eventId, + eventType: params.eventType, + topic: params.topic, + countryCode: params.countryCode, + payload: params.payload, + status: OutboxStatus.Pending, + attempts: 0, + nextAttemptAt: new Date(), + publishedAt: null, + }); + } + + async lockPendingBatch(limit: number, now: Date): Promise { + return this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(OutboxEventOrmEntity); + const staleProcessingBefore = new Date(now.getTime() - TypeOrmOutboxRepository.CLAIM_TIMEOUT_MS); + + const claimable = await repository + .createQueryBuilder('outbox') + .where( + new Brackets((qb) => { + qb.where('outbox.status = :pendingStatus AND outbox.nextAttemptAt <= :now', { + pendingStatus: OutboxStatus.Pending, + now, + }).orWhere('outbox.status = :processingStatus AND outbox.updatedAt <= :staleProcessingBefore', { + processingStatus: OutboxStatus.Processing, + staleProcessingBefore, + }); + }), + ) + .orderBy('outbox.createdAt', 'ASC') + .limit(limit) + .setLock('pessimistic_write') + .setOnLocked('skip_locked') + .getMany(); + + if (claimable.length === 0) { + return []; + } + + const claimableIds = claimable.map((event) => event.id); + + await repository + .createQueryBuilder() + .update(OutboxEventOrmEntity) + .set({ + status: OutboxStatus.Processing, + nextAttemptAt: now, + }) + .whereInIds(claimableIds) + .execute(); + + return claimable.map((event) => ({ + ...event, + status: OutboxStatus.Processing, + nextAttemptAt: now, + })); + }); + } + + async markPublished(outboxId: string, publishedAt: Date): Promise { + await this.dataSource + .getRepository(OutboxEventOrmEntity) + .createQueryBuilder() + .update(OutboxEventOrmEntity) + .set({ + status: OutboxStatus.Published, + attempts: () => 'attempts + 1', + publishedAt, + }) + .where('id = :outboxId', { outboxId }) + .andWhere('status = :processingStatus', { processingStatus: OutboxStatus.Processing }) + .execute(); + } + + async markForRetry(outboxId: string, nextAttemptAt: Date): Promise { + await this.dataSource + .getRepository(OutboxEventOrmEntity) + .createQueryBuilder() + .update(OutboxEventOrmEntity) + .set({ + status: OutboxStatus.Pending, + attempts: () => 'attempts + 1', + nextAttemptAt, + }) + .where('id = :outboxId', { outboxId }) + .andWhere('status = :processingStatus', { processingStatus: OutboxStatus.Processing }) + .execute(); + } +} diff --git a/src/infrastructure/persistence/repositories/payment-step.typeorm-repository.ts b/src/infrastructure/persistence/repositories/payment-step.typeorm-repository.ts new file mode 100644 index 00000000..ffdcbd95 --- /dev/null +++ b/src/infrastructure/persistence/repositories/payment-step.typeorm-repository.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { PaymentStep, StepStatus } from 'src/domain/entities/payment-step'; +import { PaymentStepRepository } from 'src/domain/repositories/payment-step.repository'; +import { PaymentStepOrmEntity } from 'src/infrastructure/persistence/entities/payment-step.orm-entity'; +import { TransactionContext } from 'src/infrastructure/persistence/transaction-context'; + +@Injectable() +export class TypeOrmPaymentStepRepository implements PaymentStepRepository { + constructor( + private readonly dataSource: DataSource, + private readonly transactionContext: TransactionContext, + ) {} + + async init(paymentId: string): Promise { + const manager = this.transactionContext.getEntityManager() ?? this.dataSource.manager; + await manager.getRepository(PaymentStepOrmEntity).save({ + paymentId, + fraudStatus: StepStatus.Pending, + ledgerStatus: StepStatus.Pending, + failureReason: null, + }); + } + + async markFraud(paymentId: string, status: StepStatus, reason?: string): Promise { + const manager = this.transactionContext.getEntityManager() ?? this.dataSource.manager; + await manager.getRepository(PaymentStepOrmEntity).update( + { paymentId }, + { + fraudStatus: status, + failureReason: status === StepStatus.Failed ? reason ?? 'fraud_rejected' : null, + }, + ); + } + + async markLedger(paymentId: string, status: StepStatus, reason?: string): Promise { + const manager = this.transactionContext.getEntityManager() ?? this.dataSource.manager; + await manager.getRepository(PaymentStepOrmEntity).update( + { paymentId }, + { + ledgerStatus: status, + failureReason: status === StepStatus.Failed ? reason ?? 'ledger_failed' : null, + }, + ); + } + + async getByPaymentId(paymentId: string): Promise { + const manager = this.transactionContext.getEntityManager() ?? this.dataSource.manager; + const row = await manager.getRepository(PaymentStepOrmEntity).findOneBy({ paymentId }); + if (!row) { + return null; + } + + return { + paymentId: row.paymentId, + fraudStatus: row.fraudStatus, + ledgerStatus: row.ledgerStatus, + failureReason: row.failureReason, + updatedAt: row.updatedAt, + }; + } +} diff --git a/src/infrastructure/persistence/repositories/payment.typeorm-repository.ts b/src/infrastructure/persistence/repositories/payment.typeorm-repository.ts new file mode 100644 index 00000000..35770c16 --- /dev/null +++ b/src/infrastructure/persistence/repositories/payment.typeorm-repository.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Payment, PaymentStatus } from 'src/domain/entities/payment'; +import { CreatePaymentParams, PaymentRepository } from 'src/domain/repositories/payment.repository'; +import { PaymentOrmEntity } from 'src/infrastructure/persistence/entities/payment.orm-entity'; +import { TransactionContext } from 'src/infrastructure/persistence/transaction-context'; + +@Injectable() +export class TypeOrmPaymentRepository implements PaymentRepository { + constructor( + private readonly dataSource: DataSource, + private readonly transactionContext: TransactionContext, + ) {} + + async createPending(params: CreatePaymentParams): Promise { + const manager = this.transactionContext.getEntityManager() ?? this.dataSource.manager; + const repository = manager.getRepository(PaymentOrmEntity); + + const entity = repository.create({ + id: params.id, + walletId: params.walletId, + countryCode: params.countryCode, + amount: params.amount.toFixed(2), + currency: params.currency, + status: PaymentStatus.Pending, + }); + + const saved = await repository.save(entity); + return this.toDomain(saved); + } + + async updateStatus(paymentId: string, status: PaymentStatus): Promise { + const manager = this.transactionContext.getEntityManager() ?? this.dataSource.manager; + await manager.getRepository(PaymentOrmEntity).update({ id: paymentId }, { status }); + } + + async getById(paymentId: string): Promise { + const manager = this.transactionContext.getEntityManager() ?? this.dataSource.manager; + const row = await manager.getRepository(PaymentOrmEntity).findOneBy({ id: paymentId }); + if (!row) { + return null; + } + + return this.toDomain(row); + } + + private toDomain(row: PaymentOrmEntity): Payment { + return { + id: row.id, + walletId: row.walletId, + countryCode: row.countryCode, + amount: Number(row.amount), + currency: row.currency, + status: row.status, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } +} diff --git a/src/infrastructure/persistence/repositories/processed-event.typeorm-repository.ts b/src/infrastructure/persistence/repositories/processed-event.typeorm-repository.ts new file mode 100644 index 00000000..ea8c4901 --- /dev/null +++ b/src/infrastructure/persistence/repositories/processed-event.typeorm-repository.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { ProcessedEventRepository } from 'src/domain/repositories/processed-event.repository'; +import { ProcessedEventOrmEntity } from 'src/infrastructure/persistence/entities/processed-event.orm-entity'; + +@Injectable() +export class TypeOrmProcessedEventRepository implements ProcessedEventRepository { + constructor(private readonly dataSource: DataSource) {} + + async tryMarkProcessed(consumerName: string, countryCode: string, eventId: string): Promise { + try { + await this.dataSource.getRepository(ProcessedEventOrmEntity).insert({ + consumerName, + countryCode, + eventId, + }); + return true; + } catch { + return false; + } + } +} diff --git a/src/infrastructure/persistence/transaction-context.ts b/src/infrastructure/persistence/transaction-context.ts new file mode 100644 index 00000000..3458d4ae --- /dev/null +++ b/src/infrastructure/persistence/transaction-context.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { EntityManager } from 'typeorm'; + +@Injectable() +export class TransactionContext { + private readonly storage = new AsyncLocalStorage(); + + runInTransaction(manager: EntityManager, work: () => Promise): Promise { + return this.storage.run(manager, work); + } + + getEntityManager(): EntityManager | undefined { + return this.storage.getStore(); + } +} diff --git a/src/infrastructure/persistence/typeorm-unit-of-work.ts b/src/infrastructure/persistence/typeorm-unit-of-work.ts new file mode 100644 index 00000000..318d1910 --- /dev/null +++ b/src/infrastructure/persistence/typeorm-unit-of-work.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { UnitOfWork } from 'src/domain/repositories/unit-of-work'; +import { TransactionContext } from 'src/infrastructure/persistence/transaction-context'; + +@Injectable() +export class TypeOrmUnitOfWork implements UnitOfWork { + constructor( + private readonly dataSource: DataSource, + private readonly transactionContext: TransactionContext, + ) {} + + async execute(work: () => Promise): Promise { + return this.dataSource.transaction((manager) => { + return this.transactionContext.runInTransaction(manager, work); + }); + } +} diff --git a/src/modules/common/core.module.ts b/src/modules/common/core.module.ts new file mode 100644 index 00000000..91106c2d --- /dev/null +++ b/src/modules/common/core.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { loadEnv } from 'src/config/env'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [loadEnv], + }), + ], + providers: [ + { + provide: TopicResolver, + inject: [ConfigService], + useFactory: (configService: ConfigService) => + new TopicResolver(configService.get('countryNamespaceEnabled', false)), + }, + ], + exports: [ConfigModule, TopicResolver], +}) +export class CoreModule {} diff --git a/src/modules/common/messaging.module.ts b/src/modules/common/messaging.module.ts new file mode 100644 index 00000000..4eea95c9 --- /dev/null +++ b/src/modules/common/messaging.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { EVENT_PUBLISHER, DLT_PUBLISHER } from 'src/domain/repositories/ports'; +import { ConsumerExecutorService } from 'src/domain/services/consumer-executor.service'; +import { KafkaConsumerRunner } from 'src/infrastructure/messaging/kafka-consumer-runner'; +import { KafkaPublisher } from 'src/infrastructure/messaging/kafka-publisher'; + +@Module({ + providers: [ + ConsumerExecutorService, + KafkaPublisher, + KafkaConsumerRunner, + { + provide: EVENT_PUBLISHER, + useExisting: KafkaPublisher, + }, + { + provide: DLT_PUBLISHER, + useExisting: KafkaPublisher, + }, + ], + exports: [ + ConsumerExecutorService, + KafkaPublisher, + KafkaConsumerRunner, + EVENT_PUBLISHER, + DLT_PUBLISHER, + ], +}) +export class MessagingModule {} diff --git a/src/modules/common/persistence.module.ts b/src/modules/common/persistence.module.ts new file mode 100644 index 00000000..0f20b9d7 --- /dev/null +++ b/src/modules/common/persistence.module.ts @@ -0,0 +1,76 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OUTBOX_REPOSITORY } from 'src/domain/repositories/outbox.repository'; +import { PAYMENT_REPOSITORY } from 'src/domain/repositories/payment.repository'; +import { PAYMENT_STEP_REPOSITORY } from 'src/domain/repositories/payment-step.repository'; +import { PROCESSED_EVENT_REPOSITORY } from 'src/domain/repositories/processed-event.repository'; +import { UNIT_OF_WORK } from 'src/domain/repositories/unit-of-work'; +import { OutboxEventOrmEntity } from 'src/infrastructure/persistence/entities/outbox-event.orm-entity'; +import { PaymentOrmEntity } from 'src/infrastructure/persistence/entities/payment.orm-entity'; +import { PaymentStepOrmEntity } from 'src/infrastructure/persistence/entities/payment-step.orm-entity'; +import { ProcessedEventOrmEntity } from 'src/infrastructure/persistence/entities/processed-event.orm-entity'; +import { TypeOrmOutboxRepository } from 'src/infrastructure/persistence/repositories/outbox.typeorm-repository'; +import { TypeOrmPaymentRepository } from 'src/infrastructure/persistence/repositories/payment.typeorm-repository'; +import { TypeOrmPaymentStepRepository } from 'src/infrastructure/persistence/repositories/payment-step.typeorm-repository'; +import { TypeOrmProcessedEventRepository } from 'src/infrastructure/persistence/repositories/processed-event.typeorm-repository'; +import { TransactionContext } from 'src/infrastructure/persistence/transaction-context'; +import { TypeOrmUnitOfWork } from 'src/infrastructure/persistence/typeorm-unit-of-work'; + +@Module({ + imports: [ + ConfigModule, + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.getOrThrow('dbHost'), + port: config.getOrThrow('dbPort'), + username: config.getOrThrow('dbUser'), + password: config.getOrThrow('dbPassword'), + database: config.getOrThrow('dbName'), + synchronize: true, + logging: false, + entities: [PaymentOrmEntity, PaymentStepOrmEntity, OutboxEventOrmEntity, ProcessedEventOrmEntity], + }), + }), + ], + providers: [ + TransactionContext, + TypeOrmUnitOfWork, + TypeOrmPaymentRepository, + TypeOrmPaymentStepRepository, + TypeOrmOutboxRepository, + TypeOrmProcessedEventRepository, + { + provide: UNIT_OF_WORK, + useExisting: TypeOrmUnitOfWork, + }, + { + provide: PAYMENT_REPOSITORY, + useExisting: TypeOrmPaymentRepository, + }, + { + provide: PAYMENT_STEP_REPOSITORY, + useExisting: TypeOrmPaymentStepRepository, + }, + { + provide: OUTBOX_REPOSITORY, + useExisting: TypeOrmOutboxRepository, + }, + { + provide: PROCESSED_EVENT_REPOSITORY, + useExisting: TypeOrmProcessedEventRepository, + }, + ], + exports: [ + UNIT_OF_WORK, + PAYMENT_REPOSITORY, + PAYMENT_STEP_REPOSITORY, + OUTBOX_REPOSITORY, + PROCESSED_EVENT_REPOSITORY, + TransactionContext, + ], +}) +export class PersistenceModule {} diff --git a/src/modules/consumers/fraud/fraud-consumer.module.ts b/src/modules/consumers/fraud/fraud-consumer.module.ts new file mode 100644 index 00000000..cf78fda1 --- /dev/null +++ b/src/modules/consumers/fraud/fraud-consumer.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { FraudConsumerService } from 'src/domain/services/fraud-consumer.service'; +import { CoreModule } from 'src/modules/common/core.module'; +import { MessagingModule } from 'src/modules/common/messaging.module'; +import { PersistenceModule } from 'src/modules/common/persistence.module'; + +@Module({ + imports: [CoreModule, PersistenceModule, MessagingModule], + providers: [FraudConsumerService], +}) +export class FraudConsumerModule {} diff --git a/src/modules/consumers/ledger/ledger-consumer.module.ts b/src/modules/consumers/ledger/ledger-consumer.module.ts new file mode 100644 index 00000000..081b7217 --- /dev/null +++ b/src/modules/consumers/ledger/ledger-consumer.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LedgerConsumerService } from 'src/domain/services/ledger-consumer.service'; +import { CoreModule } from 'src/modules/common/core.module'; +import { MessagingModule } from 'src/modules/common/messaging.module'; +import { PersistenceModule } from 'src/modules/common/persistence.module'; + +@Module({ + imports: [CoreModule, PersistenceModule, MessagingModule], + providers: [LedgerConsumerService], +}) +export class LedgerConsumerModule {} diff --git a/src/modules/outbox/outbox-relay.module.ts b/src/modules/outbox/outbox-relay.module.ts new file mode 100644 index 00000000..dc865817 --- /dev/null +++ b/src/modules/outbox/outbox-relay.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { OutboxRelayService } from 'src/domain/services/outbox-relay.service'; +import { CoreModule } from 'src/modules/common/core.module'; +import { MessagingModule } from 'src/modules/common/messaging.module'; +import { PersistenceModule } from 'src/modules/common/persistence.module'; + +@Module({ + imports: [CoreModule, PersistenceModule, MessagingModule], + providers: [OutboxRelayService], + exports: [OutboxRelayService], +}) +export class OutboxRelayModule {} diff --git a/src/modules/payment/dto/create-payment.dto.ts b/src/modules/payment/dto/create-payment.dto.ts new file mode 100644 index 00000000..13eeeb90 --- /dev/null +++ b/src/modules/payment/dto/create-payment.dto.ts @@ -0,0 +1,18 @@ +import { IsISO31661Alpha2, IsNumber, IsPositive, IsString, Length } from 'class-validator'; + +export class CreatePaymentDto { + @IsString() + @Length(3, 64) + walletId!: string; + + @IsISO31661Alpha2() + countryCode!: string; + + @IsNumber({ maxDecimalPlaces: 2 }) + @IsPositive() + amount!: number; + + @IsString() + @Length(3, 3) + currency!: string; +} diff --git a/src/modules/payment/payment-api.module.ts b/src/modules/payment/payment-api.module.ts new file mode 100644 index 00000000..6f05e005 --- /dev/null +++ b/src/modules/payment/payment-api.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PaymentService } from 'src/domain/services/payment.service'; +import { CoreModule } from 'src/modules/common/core.module'; +import { PersistenceModule } from 'src/modules/common/persistence.module'; +import { PaymentController } from 'src/modules/payment/payment.controller'; +import { PaymentStatusQuery } from 'src/modules/payment/payment-status.query'; + +@Module({ + imports: [CoreModule, PersistenceModule], + controllers: [PaymentController], + providers: [PaymentService, PaymentStatusQuery], +}) +export class PaymentApiModule {} diff --git a/src/modules/payment/payment-status.query.ts b/src/modules/payment/payment-status.query.ts new file mode 100644 index 00000000..2d655488 --- /dev/null +++ b/src/modules/payment/payment-status.query.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { PaymentRepository, PAYMENT_REPOSITORY } from 'src/domain/repositories/payment.repository'; +import { + PaymentStepRepository, + PAYMENT_STEP_REPOSITORY, +} from 'src/domain/repositories/payment-step.repository'; + +@Injectable() +export class PaymentStatusQuery { + constructor( + @Inject(PAYMENT_REPOSITORY) + private readonly paymentRepository: PaymentRepository, + @Inject(PAYMENT_STEP_REPOSITORY) + private readonly paymentStepRepository: PaymentStepRepository, + ) {} + + async getStatus(paymentId: string): Promise<{ + paymentId: string; + status: string; + fraudStep: string; + ledgerStep: string; + consistency: 'eventual'; + note: string; + lastUpdatedAt: string; + }> { + const payment = await this.paymentRepository.getById(paymentId); + if (!payment) { + throw new NotFoundException(`payment ${paymentId} not found`); + } + + const step = await this.paymentStepRepository.getByPaymentId(paymentId); + + return { + paymentId, + status: payment.status, + fraudStep: step?.fraudStatus ?? 'pending', + ledgerStep: step?.ledgerStatus ?? 'pending', + consistency: 'eventual', + note: + 'This endpoint is eventually consistent. A recently created payment can remain pending until fraud and ledger consumers acknowledge.', + lastUpdatedAt: payment.updatedAt.toISOString(), + }; + } +} diff --git a/src/modules/payment/payment.controller.ts b/src/modules/payment/payment.controller.ts new file mode 100644 index 00000000..e6c42629 --- /dev/null +++ b/src/modules/payment/payment.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { CreatePaymentCommand, PaymentService } from 'src/domain/services/payment.service'; +import { CreatePaymentDto } from 'src/modules/payment/dto/create-payment.dto'; +import { PaymentStatusQuery } from 'src/modules/payment/payment-status.query'; + +@Controller('payments') +export class PaymentController { + constructor( + private readonly paymentService: PaymentService, + private readonly paymentStatusQuery: PaymentStatusQuery, + ) {} + + @Post() + async createPayment(@Body() dto: CreatePaymentDto): Promise<{ paymentId: string; status: string }> { + const command: CreatePaymentCommand = { + walletId: dto.walletId, + countryCode: dto.countryCode, + amount: dto.amount, + currency: dto.currency, + }; + + return this.paymentService.createPayment(command); + } + + @Get(':paymentId/status') + async getStatus(@Param('paymentId') paymentId: string): Promise<{ + paymentId: string; + status: string; + fraudStep: string; + ledgerStep: string; + consistency: 'eventual'; + note: string; + lastUpdatedAt: string; + }> { + return this.paymentStatusQuery.getStatus(paymentId); + } +} diff --git a/src/modules/status-saga/status-saga.module.ts b/src/modules/status-saga/status-saga.module.ts new file mode 100644 index 00000000..91d82e5d --- /dev/null +++ b/src/modules/status-saga/status-saga.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { StatusSagaService } from 'src/domain/services/status-saga.service'; +import { CoreModule } from 'src/modules/common/core.module'; +import { MessagingModule } from 'src/modules/common/messaging.module'; +import { PersistenceModule } from 'src/modules/common/persistence.module'; + +@Module({ + imports: [CoreModule, PersistenceModule, MessagingModule], + providers: [StatusSagaService], +}) +export class StatusSagaModule {} diff --git a/test-visual/public/app.js b/test-visual/public/app.js new file mode 100644 index 00000000..29230b90 --- /dev/null +++ b/test-visual/public/app.js @@ -0,0 +1,478 @@ +const state = { + running: false, + resultAction: 'reset', + countries: {}, +}; + +const $ = (selector) => document.querySelector(selector); + +const els = { + amount: $('#amount'), + country: $('#country'), + currency: $('#currency'), + scenario: $('#scenario'), + recipient: $('#recipient'), + message: $('#message'), + yapearButton: $('#yapearButton'), + resetButton: $('#resetButton'), + consoleOutput: $('#consoleOutput'), + eventInspector: $('#eventInspector'), + healthGrid: $('#healthGrid'), + timeline: $('#timeline'), + serviceMap: $('#serviceMap'), + narratorText: $('#narratorText'), + dbEvidence: $('#dbEvidence'), + countryAvailability: $('#countryAvailability'), + paymentForm: $('#paymentForm'), + phoneResult: $('#phoneResult'), + phoneResultIcon: $('#phoneResultIcon'), + phoneResultKicker: $('#phoneResultKicker'), + phoneResultTitle: $('#phoneResultTitle'), + phoneResultAmount: $('#phoneResultAmount'), + phoneResultMeta: $('#phoneResultMeta'), + phoneResultDetail: $('#phoneResultDetail'), + phoneResultButton: $('#phoneResultButton'), +}; + +const currencySymbols = { + PEN: 'S/', + MXN: '$', + USD: 'US$', + COP: '$', + CLP: '$', + ARS: '$', + BRL: 'R$', +}; + +const services = { + api: { title: 'Payment API', subtitle: 'Recibe el yape', state: 'waiting', detail: 'Esperando request' }, + outbox: { title: 'Outbox DB', subtitle: 'Commit local', state: 'waiting', detail: 'payment + outbox en una transaccion' }, + relay: { title: 'Outbox Relay', subtitle: 'Publicador separado', state: 'waiting', detail: 'Kafka se llama despues del commit' }, + kafka: { title: 'Kafka Topic', subtitle: 'Eventos por pais', state: 'waiting', detail: 'pe.payments.* / mx.payments.*' }, + fraud: { title: 'Fraud Consumer', subtitle: 'Riesgo independiente', state: 'waiting', detail: 'Idempotente por eventId' }, + ledger: { title: 'Ledger Consumer', subtitle: 'Doble entrada', state: 'waiting', detail: 'Aislado por pais' }, + saga: { title: 'Status Saga', subtitle: 'Consistencia eventual', state: 'waiting', detail: 'Cierra settled o failed' }, + dlt: { title: 'DLT', subtitle: 'Fallos irreparables', state: 'waiting', detail: 'No se pierden mensajes agotados' }, +}; + +function appendConsole(line, level = 'info') { + const prefix = { + info: 'INFO ', + ok: 'OK ', + warn: 'WARN ', + error: 'ERR ', + }[level] || 'INFO '; + + els.consoleOutput.textContent += `\n${new Date().toLocaleTimeString()} ${prefix}${line}`; + els.consoleOutput.scrollTop = els.consoleOutput.scrollHeight; +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function prettyJson(value) { + if (value === null || value === undefined) { + return 'null'; + } + + return escapeHtml(JSON.stringify(value, null, 2)); +} + +function moneyLabel() { + const currency = els.currency.value; + const amount = Number(els.amount.value || 0).toFixed(2); + return `${currencySymbols[currency] || currency} ${amount}`; +} + +function showPaymentForm() { + els.paymentForm.classList.remove('hidden'); + els.phoneResult.classList.add('hidden'); +} + +function showPhoneState({ status, title, detail, action }) { + const labels = { + processing: 'Procesando', + success: 'Yape exitoso', + failed: 'No completado', + pending: 'En proceso', + }; + const icons = { + processing: '...', + success: 'OK', + failed: '!', + pending: '...', + }; + + state.resultAction = action || 'reset'; + els.paymentForm.classList.add('hidden'); + els.phoneResult.className = `phone-result ${status || 'processing'}`; + els.phoneResultIcon.className = `result-icon ${status || 'processing'}`; + els.phoneResultIcon.textContent = icons[status] || '...'; + els.phoneResultKicker.textContent = labels[status] || 'Procesando'; + els.phoneResultTitle.textContent = title || 'Procesando tu yape'; + els.phoneResultAmount.textContent = moneyLabel(); + els.phoneResultMeta.textContent = `${els.country.value.toUpperCase()} · ${els.currency.value}`; + els.phoneResultDetail.textContent = detail || `Para ${els.recipient.value}`; + els.phoneResultButton.textContent = action === 'retry' ? 'Reintentar' : status === 'processing' ? 'Procesando...' : 'Volver a yapear'; + els.phoneResultButton.disabled = status === 'processing'; +} + +function resetTimeline() { + els.timeline.querySelectorAll('[data-step]').forEach((node) => { + node.className = ''; + }); +} + +function renderServices() { + els.serviceMap.innerHTML = Object.entries(services) + .map(([key, service]) => ` +
+ ${service.title} + ${service.subtitle} +

${service.detail}

+
+ `) + .join(''); +} + +function setService(service, state, detail) { + if (!services[service]) return; + services[service].state = state || services[service].state; + services[service].detail = detail || services[service].detail; + renderServices(); +} + +function setStep(step, status) { + const node = els.timeline.querySelector(`[data-step="${step}"]`); + if (!node) return; + node.className = status; +} + +function resetDemo() { + els.consoleOutput.textContent = 'Listo para yapear...'; + els.eventInspector.textContent = '{}'; + els.dbEvidence.innerHTML = '
Ejecuta un escenario para ver payments, outbox_events, payment_steps y processed_events.
'; + els.narratorText.textContent = 'Elige un escenario y presiona Yapear.'; + state.resultAction = 'reset'; + els.phoneResultButton.disabled = false; + showPaymentForm(); + resetTimeline(); + Object.keys(services).forEach((key) => { + services[key].state = 'waiting'; + }); + renderServices(); +} + +function renderDbList(title, rows, emptyMessage) { + const content = rows && rows.length + ? rows.map((row) => `
${prettyJson(row)}
`).join('') + : `

${escapeHtml(emptyMessage)}

`; + + return ` +
+ ${escapeHtml(title)} + ${content} +
+ `; +} + +function renderDbObject(title, value, emptyMessage) { + return renderDbList(title, value ? [value] : [], emptyMessage); +} + +function renderDbSnapshot(snapshot) { + const summary = (snapshot.summary || []) + .map((item) => `${escapeHtml(item)}`) + .join(''); + + els.dbEvidence.innerHTML = ` +
+ ${escapeHtml(snapshot.stage || 'DB snapshot')} +

${escapeHtml(snapshot.note || 'Snapshot de evidencia persistida.')}

+
${summary}
+
+
+ ${renderDbObject('payments', snapshot.payment, 'Sin fila payment para este escenario.')} + ${renderDbObject('payment_steps', snapshot.paymentStep, 'Todavia no hay ACK persistido de fraud/ledger.')} + ${renderDbList('outbox_events', snapshot.outboxEvents, 'Sin outbox asociado.')} + ${renderDbList('processed_events', snapshot.processedEvents, 'Aun no hay marcas idempotentes de consumers.')} +
+ `; +} + +function statusClass(status) { + return status === 'running' ? 'running' : status === 'missing' || status === 'exited' ? status : ''; +} + +function healthItem(name, status) { + return `
${name}${status}
`; +} + +function availabilityClass(status) { + return status === 'available' ? 'available' : status === 'missing' ? 'missing' : 'degraded'; +} + +function renderCountryAvailability() { + const countries = Object.entries(state.countries); + if (!countries.length) { + els.countryAvailability.innerHTML = ''; + return; + } + + els.countryAvailability.innerHTML = countries + .map(([country, info]) => ` +
+ ${country.toUpperCase()} + ${escapeHtml(info.availability || 'unknown')} +

${escapeHtml(info.detail || `currency=${info.currency || 'N/A'}, ledger=${info.ledgerConsumer || 'unknown'}, saga=${info.statusSaga || 'unknown'}`)}

+
+ `) + .join(''); +} + +function syncCountryOptions(countries) { + const previous = els.country.value; + const entries = Object.entries(countries); + if (!entries.length) return; + + els.country.innerHTML = entries + .map(([country, info]) => ``) + .join(''); + + els.country.value = countries[previous] ? previous : entries[0][0]; + syncCurrency(); +} + +function setCountryAvailability(country, availability, detail) { + const normalized = String(country || '').toLowerCase(); + if (!normalized) return; + + state.countries[normalized] = { + ...(state.countries[normalized] || {}), + availability, + detail, + }; + renderCountryAvailability(); +} + +async function loadHealth() { + try { + const res = await fetch('/api/health'); + const health = await res.json(); + state.countries = health.countries || {}; + syncCountryOptions(state.countries); + renderCountryAvailability(); + + const countryHealth = Object.entries(state.countries).flatMap(([country, info]) => [ + healthItem(`${country.toUpperCase()} Ledger`, info.ledgerConsumer), + healthItem(`${country.toUpperCase()} Saga`, info.statusSaga), + ]); + + els.healthGrid.innerHTML = [ + healthItem('Payment API', health.paymentApi), + healthItem('Kafka', health.kafka), + healthItem('Outbox Relay', health.outboxRelay), + healthItem('Fraud', health.fraudConsumer), + ...countryHealth, + ].join(''); + } catch (error) { + els.healthGrid.innerHTML = healthItem('Health', 'unavailable'); + } +} + +function syncCurrency() { + const country = els.country.value; + const currency = state.countries[country]?.currency || (country === 'mx' ? 'MXN' : 'PEN'); + + if (!Array.from(els.currency.options).some((option) => option.value === currency)) { + const option = document.createElement('option'); + option.value = currency; + option.textContent = currencySymbols[currency] || currency; + els.currency.appendChild(option); + } + + els.currency.value = currency; +} + +function applyScenarioDefaults() { + const scenario = els.scenario.value; + if (scenario === 'fraud-rejected') { + els.amount.value = '1500'; + } + if (scenario === 'mx-outage-pe-available') { + els.country.value = 'pe'; + syncCurrency(); + } + if ((scenario === 'success' || scenario === 'idempotency-replay' || scenario === 'country-isolation' || scenario === 'mx-outage-pe-available') && Number(els.amount.value) > 500) { + els.amount.value = '25'; + } +} + +async function runScenario() { + if (state.running) return; + + state.running = true; + els.yapearButton.disabled = true; + resetDemo(); + syncCurrency(); + applyScenarioDefaults(); + showPhoneState({ + status: 'processing', + title: 'Procesando tu yape', + detail: 'Estamos creando el pago y esperando confirmacion del pipeline.', + action: 'none', + }); + + appendConsole(`Iniciando escenario ${els.scenario.value} para ${els.country.value.toUpperCase()}`, 'info'); + + const payload = { + scenario: els.scenario.value, + country: els.country.value, + currency: els.currency.value, + amount: Number(els.amount.value), + walletId: `visual-${els.country.value}-${Date.now()}`, + recipient: els.recipient.value, + message: els.message.value, + }; + + try { + const response = await fetch('/api/scenario', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.body) { + throw new Error('El navegador no pudo abrir el stream de eventos.'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() || ''; + + for (const chunk of chunks) { + const line = chunk.split('\n').find((part) => part.startsWith('data: ')); + if (!line) continue; + + const event = JSON.parse(line.slice(6)); + handleEvent(event); + } + } + } catch (error) { + appendConsole(error.message || String(error), 'error'); + showPhoneState({ + status: 'failed', + title: 'No pudimos completar tu yape', + detail: error.message || String(error), + action: 'retry', + }); + } finally { + state.running = false; + els.yapearButton.disabled = false; + await loadHealth(); + } +} + +function handleEvent(event) { + if (event.type === 'step') { + setStep(event.step, event.status); + return; + } + + if (event.type === 'service') { + setService(event.service, event.status, event.detail); + return; + } + + if (event.type === 'narrative') { + els.narratorText.textContent = event.message; + appendConsole(event.message, event.level || 'info'); + return; + } + + if (event.type === 'phone-state') { + showPhoneState(event); + return; + } + + if (event.type === 'country-status') { + setCountryAvailability(event.country, event.availability, event.detail); + appendConsole(`${String(event.country || '').toUpperCase()} availability=${event.availability}: ${event.detail}`, event.availability === 'available' ? 'ok' : 'warn'); + return; + } + + if (event.type === 'outbox') { + setService('outbox', event.status === 'published' ? 'ok' : 'running', `status=${event.status}, attempts=${event.attempts}, topic=${event.topic}`); + appendConsole(`Outbox ${event.status} attempts=${event.attempts} topic=${event.topic}`, event.status === 'published' ? 'ok' : 'info'); + return; + } + + if (event.type === 'idempotency') { + appendConsole(event.message, event.level || 'ok'); + setService('fraud', 'ok', event.fraud || 'duplicate ignored'); + setService('ledger', 'ok', event.ledger || 'duplicate ignored'); + els.eventInspector.textContent = JSON.stringify(event.rows || [], null, 2); + return; + } + + if (event.type === 'db-snapshot') { + renderDbSnapshot(event.snapshot); + appendConsole(`DB snapshot: ${event.snapshot.stage}`, 'ok'); + return; + } + + if (event.type === 'kafka-event') { + appendConsole(`${event.topic}`, event.level); + els.eventInspector.textContent = JSON.stringify(event.event, null, 2); + return; + } + + if (event.type === 'status') { + appendConsole(`${event.message} ${JSON.stringify(event.status)}`, event.level); + return; + } + + if (event.type === 'summary') { + appendConsole(event.message, event.level); + return; + } + + if (event.type === 'done') { + appendConsole('Escenario finalizado.', 'ok'); + return; + } + + appendConsole(event.message || JSON.stringify(event), event.level || 'info'); +} + +els.yapearButton.addEventListener('click', runScenario); +els.resetButton.addEventListener('click', resetDemo); +els.phoneResultButton.addEventListener('click', () => { + if (state.resultAction === 'retry') { + resetDemo(); + runScenario(); + return; + } + + resetDemo(); +}); +els.country.addEventListener('change', syncCurrency); +els.scenario.addEventListener('change', applyScenarioDefaults); + +loadHealth(); +renderServices(); +setInterval(loadHealth, 8000); diff --git a/test-visual/public/index.html b/test-visual/public/index.html new file mode 100644 index 00000000..11adcd78 --- /dev/null +++ b/test-visual/public/index.html @@ -0,0 +1,141 @@ + + + + + + Yape Visual Demo + + + +
+
+
+ 12:30 + LTE +
+ +
+ + Yapear a + +
+ +
+ + + +
+ + +
+ +

Limite por yapeo S/500, limite total por dia S/2,000

+ +
+ + + +
+ + + +
+ + +
+ + +
+ + +
+ +
+
+
+

Challenge 1

+

Payment settlement pipeline

+
+ Kafka UI +
+ +
+
+ +
+ Payment API + Outbox + Kafka + Fraud + Ledger + Saga + DLT +
+ +
+ +
+
Modo seguimiento
+

Elige un escenario y presiona Yapear.

+
+ +
+
+
CMD en vivo
+
Listo para yapear...
+
+ +
+
Inspector de evento
+
{}
+
+
+ +
+
Database Evidence
+

Aqui se muestran las filas reales que prueban outbox, idempotencia y consistencia eventual.

+
+
Ejecuta un escenario para ver payments, outbox_events, payment_steps y processed_events.
+
+
+
+
+ + + + diff --git a/test-visual/public/styles.css b/test-visual/public/styles.css new file mode 100644 index 00000000..cf289b5a --- /dev/null +++ b/test-visual/public/styles.css @@ -0,0 +1,713 @@ +:root { + --ink: #252131; + --muted: #6d687c; + --line: #e6e1ed; + --brand: #74218a; + --brand-soft: #f3e7f7; + --money: #09bda5; + --ok: #13a56f; + --warn: #d88900; + --error: #c83f5a; + --paper: #fbf9fd; + --cmd: #101418; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(135deg, rgba(9, 189, 165, 0.12), transparent 34%), + linear-gradient(315deg, rgba(116, 33, 138, 0.16), transparent 38%), + #f5f2f8; + color: var(--ink); + font-family: Avenir Next, Trebuchet MS, Verdana, sans-serif; +} + +button, +input, +select { + font: inherit; +} + +.shell { + display: grid; + grid-template-columns: minmax(320px, 430px) minmax(360px, 1fr); + gap: 28px; + width: min(1280px, calc(100vw - 32px)); + margin: 20px auto; + align-items: start; +} + +.phone { + min-height: 860px; + border: 8px solid #d8d3df; + border-radius: 48px; + background: white; + overflow: hidden; + box-shadow: 0 22px 48px rgba(37, 33, 49, 0.18); +} + +.hidden { + display: none !important; +} + +.status-bar { + height: 58px; + padding: 18px 32px 0; + color: white; + background: var(--brand); + display: flex; + justify-content: space-between; + font-weight: 700; +} + +.topbar { + height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + font-size: 24px; +} + +.ghost-button { + width: 42px; + height: 42px; + border: 0; + background: transparent; + color: var(--ink); + font-size: 46px; + line-height: 1; + cursor: pointer; +} + +.recipient-label { + display: block; + margin-top: 34px; + color: var(--muted); + text-align: center; + font-size: 13px; + text-transform: uppercase; +} + +.recipient-input { + display: block; + width: calc(100% - 72px); + margin: 8px auto 54px; + border: 0; + color: var(--brand); + text-align: center; + font-size: 30px; + font-weight: 800; + outline: 0; +} + +.amount-line { + display: flex; + justify-content: center; + align-items: baseline; + gap: 8px; +} + +.currency-select { + width: 92px; + border: 0; + color: #c98bd7; + background: transparent; + font-size: 40px; + font-weight: 800; + outline: 0; +} + +.amount-input { + width: 180px; + border: 0; + color: var(--brand); + font-size: 96px; + font-weight: 700; + outline: 0; +} + +.limit-copy { + width: max-content; + max-width: calc(100% - 64px); + margin: 20px auto 72px; + padding: 8px 18px; + border-radius: 8px; + background: #f3f0f7; + color: var(--muted); + font-weight: 700; + text-align: center; +} + +.form-row { + display: grid; + grid-template-columns: 92px 1fr; + gap: 12px; + padding: 0 28px; +} + +.form-row label { + color: var(--muted); + font-size: 13px; + font-weight: 800; + text-transform: uppercase; +} + +.form-row select, +.message-input { + width: 100%; + margin-top: 6px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: white; + color: var(--ink); +} + +.message-input { + display: block; + width: calc(100% - 56px); + margin: 18px auto 18px; + text-align: center; +} + +.actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + padding: 0 28px 22px; +} + +.outline-button, +.primary-button { + min-height: 54px; + border-radius: 8px; + font-weight: 900; + cursor: pointer; +} + +.outline-button { + border: 2px solid var(--money); + color: var(--money); + background: white; +} + +.primary-button { + border: 0; + color: white; + background: var(--money); +} + +.primary-button:disabled { + opacity: 0.6; + cursor: wait; +} + +.keypad { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + padding: 10px 24px 28px; + background: #d7d9df; +} + +.keypad span { + min-height: 58px; + border-radius: 6px; + background: white; + display: grid; + place-items: center; + font-size: 28px; + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.14); +} + +.keypad small { + font-size: 11px; + font-weight: 900; +} + +.keypad .zero { + grid-column: 2; +} + +.phone-result { + min-height: 700px; + padding: 72px 34px 34px; + text-align: center; + display: grid; + align-content: start; + justify-items: center; +} + +.result-icon { + width: 92px; + height: 92px; + border-radius: 999px; + display: grid; + place-items: center; + color: white; + font-size: 30px; + font-weight: 1000; + letter-spacing: -0.04em; + box-shadow: 0 16px 32px rgba(37, 33, 49, 0.18); +} + +.result-icon.processing, +.result-icon.pending { + background: var(--warn); +} + +.result-icon.success { + background: var(--money); +} + +.result-icon.failed { + background: var(--error); +} + +.result-kicker { + margin: 24px 0 0; + color: var(--muted); + font-size: 13px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.phone-result h2 { + margin: 10px 0 0; + color: var(--brand); + font-size: 30px; +} + +.result-amount { + margin: 28px 0 0; + color: var(--ink); + font-size: 52px; + font-weight: 900; +} + +.result-meta { + margin: 6px 0 0; + padding: 6px 12px; + border-radius: 999px; + background: var(--brand-soft); + color: var(--brand); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; +} + +.result-detail { + width: min(320px, 100%); + margin: 18px auto 34px; + color: var(--muted); + font-size: 16px; + line-height: 1.45; +} + +.phone-result .primary-button { + width: 100%; +} + +.ops { + min-height: 860px; + display: grid; + gap: 18px; +} + +.ops-header { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: center; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--money); + font-weight: 900; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: 36px; +} + +.kafka-link { + border-radius: 8px; + padding: 12px 16px; + background: var(--ink); + color: white; + text-decoration: none; + font-weight: 900; +} + +.health-grid { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 10px; +} + +.health-item { + border-left: 4px solid var(--line); + padding: 10px 12px; + background: rgba(255, 255, 255, 0.72); + border-radius: 8px; +} + +.health-item strong { + display: block; + font-size: 13px; +} + +.health-item span { + color: var(--muted); + font-size: 12px; +} + +.health-item.running { + border-color: var(--ok); +} + +.health-item.exited, +.health-item.missing { + border-color: var(--error); +} + +.country-availability { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + gap: 10px; +} + +.country-card { + border: 1px solid var(--line); + border-left: 6px solid var(--line); + border-radius: 8px; + padding: 12px; + background: white; +} + +.country-card strong { + display: inline-block; + margin-right: 8px; + font-size: 20px; +} + +.country-card span { + display: inline-block; + padding: 4px 9px; + border-radius: 999px; + color: white; + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.country-card p { + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.country-card.available { + border-left-color: var(--ok); +} + +.country-card.available span { + background: var(--ok); +} + +.country-card.degraded, +.country-card.missing { + border-left-color: var(--error); + background: #fff6f7; +} + +.country-card.degraded span, +.country-card.missing span { + background: var(--error); +} + +.timeline { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px; +} + +.timeline span { + min-height: 54px; + border: 1px solid var(--line); + border-radius: 8px; + background: white; + display: grid; + place-items: center; + text-align: center; + color: var(--muted); + font-size: 13px; + font-weight: 900; +} + +.timeline span.running { + border-color: var(--warn); + background: #fff7e5; + color: var(--warn); +} + +.timeline span.ok { + border-color: var(--ok); + background: #e8f8f1; + color: var(--ok); +} + +.timeline span.failed { + border-color: var(--error); + background: #fdecef; + color: var(--error); +} + +.timeline span.retrying { + border-color: var(--warn); + background: #fff7e5; + color: var(--warn); +} + +.service-map { + display: grid; + grid-template-columns: repeat(4, minmax(140px, 1fr)); + gap: 10px; +} + +.service-card { + min-height: 118px; + border: 1px solid var(--line); + border-top: 5px solid var(--line); + border-radius: 8px; + padding: 12px; + background: white; +} + +.service-card strong, +.service-card span { + display: block; +} + +.service-card strong { + font-size: 14px; +} + +.service-card span { + margin-top: 4px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.service-card p { + margin: 10px 0 0; + color: var(--ink); + font-size: 12px; + line-height: 1.35; +} + +.service-card.running, +.service-card.retrying { + border-top-color: var(--warn); +} + +.service-card.ok { + border-top-color: var(--ok); +} + +.service-card.failed { + border-top-color: var(--error); +} + +.narrator-panel { + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: white; +} + +.narrator-panel p { + margin: 0; + padding: 16px; + color: var(--ink); + font-size: 15px; + line-height: 1.45; +} + +.panels { + display: grid; + grid-template-columns: minmax(320px, 1.35fr) minmax(280px, 0.85fr); + gap: 14px; + min-height: 560px; +} + +.console-panel, +.inspector-panel { + border-radius: 8px; + overflow: hidden; + background: white; + border: 1px solid var(--line); +} + +.panel-title { + padding: 12px 14px; + background: var(--ink); + color: white; + font-weight: 900; +} + +.console-output, +.event-inspector { + height: 520px; + margin: 0; + padding: 16px; + overflow: auto; + background: var(--cmd); + color: #d9ffe8; + font-family: Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; +} + +.event-inspector { + color: #dce9ff; +} + +.db-panel { + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: white; +} + +.db-copy { + margin: 0; + padding: 14px 16px 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.db-evidence { + padding: 14px; +} + +.db-empty { + border: 1px dashed var(--line); + border-radius: 8px; + padding: 16px; + color: var(--muted); + background: #faf8f4; +} + +.db-stage { + border: 1px solid var(--line); + border-left: 5px solid var(--accent); + border-radius: 8px; + padding: 14px; + margin-bottom: 12px; + background: #fffdf6; +} + +.db-stage strong { + display: block; + font-size: 15px; +} + +.db-stage p { + margin: 6px 0 0; + color: var(--ink); + line-height: 1.45; +} + +.db-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.db-summary span { + padding: 7px 9px; + border-radius: 999px; + background: var(--ink); + color: white; + font-size: 12px; + font-weight: 900; +} + +.db-grid { + display: grid; + grid-template-columns: repeat(2, minmax(260px, 1fr)); + gap: 12px; +} + +.db-card { + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfbfb; + overflow: hidden; +} + +.db-card strong { + display: block; + padding: 10px 12px; + background: #ede7d7; + color: var(--ink); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.db-card pre, +.muted-row { + margin: 0; + padding: 12px; +} + +.db-card pre { + overflow: auto; + background: #101820; + color: #c7ffdf; + font-family: Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.5; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.muted-row { + color: var(--muted); + font-size: 13px; +} + +@media (max-width: 920px) { + .shell, + .panels { + grid-template-columns: 1fr; + } + + .phone { + min-height: auto; + } + + .health-grid, + .country-availability, + .timeline, + .service-map, + .db-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/test-visual/server.js b/test-visual/server.js new file mode 100644 index 00000000..bfbb962e --- /dev/null +++ b/test-visual/server.js @@ -0,0 +1,937 @@ +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const PORT = Number(process.env.PORT || 4000); +const PAYMENT_API_URL = process.env.PAYMENT_API_URL || 'http://payment-api:3000'; +const KAFKA_CONTAINER = process.env.KAFKA_CONTAINER || 'yape-kafka'; +const POSTGRES_CONTAINER = process.env.POSTGRES_CONTAINER || 'yape-postgres'; +const DB_USER = process.env.DB_USER || 'postgres'; +const DB_PASSWORD = process.env.DB_PASSWORD || 'postgres'; +const DB_NAME = process.env.DB_NAME || 'yape'; +const PUBLIC_DIR = path.join(__dirname, 'public'); + +const CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', +}; + +const TOPIC_SUFFIXES = [ + 'payment.created.v1', + 'fraud.assessed.v1', + 'ledger.posted.v1', + 'payment.settled.v1', + 'payment.failed.v1', + 'payment.created.v1.dlt', + 'fraud.assessed.v1.dlt', + 'ledger.posted.v1.dlt', +]; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(payload)); +} + +function parseBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (error) { + reject(error); + } + }); + }); +} + +function run(command, args, options = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...options }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', (error) => { + resolve({ code: 1, stdout, stderr: error.message }); + }); + child.on('close', (code) => { + resolve({ code: code ?? 0, stdout, stderr }); + }); + }); +} + +async function containerStatus(name) { + const result = await run('docker', ['inspect', '-f', '{{.State.Status}}', name]); + if (result.code !== 0) { + return 'missing'; + } + + return result.stdout.trim() || 'unknown'; +} + +async function countryContainerId(country, service) { + const result = await run('docker', [ + 'ps', + '-a', + '--filter', + `label=com.docker.compose.project=yape-${country}`, + '--filter', + `label=com.docker.compose.service=${service}`, + '--format', + '{{.ID}}', + ]); + + return result.stdout.trim().split('\n').filter(Boolean)[0] || ''; +} + +async function countryServiceStatus(country, service) { + const id = await countryContainerId(country, service); + if (!id) { + return 'missing'; + } + + const result = await run('docker', ['inspect', '-f', '{{.State.Status}}', id]); + return result.code === 0 ? result.stdout.trim() : 'unknown'; +} + +async function countryServiceEnv(country, service, key) { + const id = await countryContainerId(country, service); + if (!id) { + return ''; + } + + const result = await run('docker', [ + 'inspect', + '-f', + `{{range .Config.Env}}{{println .}}{{end}}`, + id, + ]); + + if (result.code !== 0) { + return ''; + } + + const prefix = `${key}=`; + const line = result.stdout + .split('\n') + .find((entry) => entry.startsWith(prefix)); + + return line ? line.slice(prefix.length).trim() : ''; +} + +async function countryCurrency(country) { + const fromLedger = await countryServiceEnv(country, 'ledger-consumer', 'COUNTRY_CURRENCY'); + const fromSaga = fromLedger || (await countryServiceEnv(country, 'status-saga', 'COUNTRY_CURRENCY')); + return fromSaga || defaultCurrencyForCountry(country); +} + +function availabilityFrom(ledgerStatus, sagaStatus) { + if (ledgerStatus === 'running' && sagaStatus === 'running') { + return 'available'; + } + if (ledgerStatus === 'missing' && sagaStatus === 'missing') { + return 'missing'; + } + return 'degraded'; +} + +async function stopCountryService(country, service) { + const id = await countryContainerId(country, service); + if (!id) { + throw new Error(`${service} container for ${country} was not found`); + } + + await run('docker', ['stop', id]); +} + +async function startCountryService(country, service) { + const id = await countryContainerId(country, service); + if (!id) { + throw new Error(`${service} container for ${country} was not found`); + } + + await run('docker', ['start', id]); +} + +async function health() { + const [api, kafka, relay, fraud] = await Promise.all([ + containerStatus('yape-payment-api'), + containerStatus(KAFKA_CONTAINER), + containerStatus('yape-outbox-relay'), + containerStatus('yape-fraud-consumer'), + ]); + + const countries = {}; + for (const country of supportedCountries()) { + const [ledgerConsumer, statusSaga, currency] = await Promise.all([ + countryServiceStatus(country, 'ledger-consumer'), + countryServiceStatus(country, 'status-saga'), + countryCurrency(country), + ]); + + countries[country] = { + currency, + ledgerConsumer, + statusSaga, + availability: availabilityFrom(ledgerConsumer, statusSaga), + }; + } + + return { + paymentApi: api, + kafka, + outboxRelay: relay, + fraudConsumer: fraud, + countries, + }; +} + +function topic(country, suffix) { + return `${country}.payments.${suffix}`; +} + +function paymentIdFromEvent(event) { + return event?.payload?.paymentId || event?.aggregateId || ''; +} + +function normalizeCountry(country) { + return String(country || 'pe').trim().toLowerCase(); +} + +function supportedCountries() { + return String(process.env.SUPPORTED_COUNTRIES || 'pe,mx') + .split(',') + .map((country) => normalizeCountry(country)) + .filter((country) => /^[a-z]{2}$/.test(country)); +} + +function defaultCurrencyForCountry(country) { + return { + pe: 'PEN', + mx: 'MXN', + co: 'COP', + cl: 'CLP', + ar: 'ARS', + br: 'BRL', + ec: 'USD', + }[country] || country.toUpperCase(); +} + +function writeEvent(res, event) { + res.write(`data: ${JSON.stringify({ at: new Date().toISOString(), ...event })}\n\n`); +} + +function serviceEvent(service, status, detail) { + return { type: 'service', service, status, detail }; +} + +function narrative(message, level = 'info') { + return { type: 'narrative', level, message }; +} + +function phoneState(status, title, detail, action = 'reset') { + return { type: 'phone-state', status, title, detail, action }; +} + +async function psql(query) { + const result = await run('docker', [ + 'exec', + '-e', + `PGPASSWORD=${DB_PASSWORD}`, + POSTGRES_CONTAINER, + 'psql', + '-U', + DB_USER, + '-d', + DB_NAME, + '-t', + '-A', + '-F', + '|', + '-c', + query, + ]); + + if (result.code !== 0) { + throw new Error(`psql failed: ${result.stderr || result.stdout}`); + } + + return result.stdout.trim(); +} + +function sqlLiteral(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +async function psqlJson(query, fallback) { + const raw = await psql(query); + if (!raw) { + return fallback; + } + + return JSON.parse(raw); +} + +async function readOutbox(paymentId) { + const rows = await psql( + `select status, attempts, topic, "eventId" from outbox_events where "aggregateId"=${sqlLiteral(paymentId)} order by "createdAt" desc limit 1;`, + ); + const [row] = rows.split('\n').filter(Boolean); + if (!row) { + return null; + } + + const [status, attempts, outboxTopic, eventId] = row.split('|'); + return { status, attempts: Number(attempts), topic: outboxTopic, eventId }; +} + +async function processedRows(country, eventIds) { + const ids = eventIds.map((id) => sqlLiteral(id)).join(','); + if (!ids) { + return []; + } + + const rows = await psql( + `select "consumerName", "countryCode", "eventId" from processed_events where lower("countryCode")=lower(${sqlLiteral(country)}) and "eventId" in (${ids}) order by "consumerName", "eventId";`, + ); + + return rows + .split('\n') + .filter(Boolean) + .map((row) => { + const [consumerName, countryCode, eventId] = row.split('|'); + return { consumerName, countryCode, eventId }; + }); +} + +async function dbSnapshot(paymentId, stage, note) { + if (!paymentId) { + return { + stage, + note, + payment: null, + paymentStep: null, + outboxEvents: [], + processedEvents: [], + summary: ['No hay paymentId: este escenario inyecta un mensaje directo en Kafka, por eso la evidencia principal vive en el DLT.'], + }; + } + + const paymentIdSql = sqlLiteral(paymentId); + const payment = await psqlJson( + `select coalesce((select row_to_json(t)::text from ( + select id, "walletId", "countryCode", amount::text as amount, currency, status, "createdAt", "updatedAt" + from payments + where id=${paymentIdSql} + ) t), 'null');`, + null, + ); + const paymentStep = await psqlJson( + `select coalesce((select row_to_json(t)::text from ( + select "paymentId", "fraudStatus", "ledgerStatus", "failureReason", "updatedAt" + from payment_steps + where "paymentId"=${paymentIdSql} + ) t), 'null');`, + null, + ); + const outboxEvents = await psqlJson( + `select coalesce((select json_agg(t)::text from ( + select id, "eventId", "eventType", topic, "countryCode", status, attempts, "publishedAt", "createdAt", "updatedAt" + from outbox_events + where "aggregateId"=${paymentIdSql} + order by "createdAt" + ) t), '[]');`, + [], + ); + const processedEvents = await psqlJson( + `with base as ( + select "eventId"::varchar as id + from outbox_events + where "aggregateId"=${paymentIdSql} + ) + select coalesce((select json_agg(t)::text from ( + select "consumerName", "countryCode", "eventId", "processedAt" + from processed_events pe + where exists ( + select 1 + from base + where pe."eventId" = base.id + or pe."eventId" like base.id || '.%' + ) + order by "consumerName", "processedAt" + ) t), '[]');`, + [], + ); + + const latestOutbox = outboxEvents[outboxEvents.length - 1]; + const summary = [ + payment ? `payments.status=${payment.status}` : 'payments: todavia no hay fila visible', + latestOutbox ? `outbox.status=${latestOutbox.status}, attempts=${latestOutbox.attempts}` : 'outbox: sin fila', + paymentStep ? `fraud=${paymentStep.fraudStatus}, ledger=${paymentStep.ledgerStatus}` : 'payment_steps: pendiente de consumidores', + `processed_events=${processedEvents.length}`, + ]; + + return { + stage, + note, + payment, + paymentStep, + outboxEvents, + processedEvents, + summary, + }; +} + +async function emitDbSnapshot({ paymentId, stage, note, emit }) { + try { + const snapshot = await dbSnapshot(paymentId, stage, note); + emit({ type: 'db-snapshot', snapshot }); + } catch (error) { + emit({ + type: 'log', + source: 'db', + level: 'warn', + message: `Could not read DB evidence: ${error instanceof Error ? error.message : String(error)}`, + }); + } +} + +async function pollOutbox({ paymentId, emit, timeoutMs = 12000 }) { + const start = Date.now(); + let last = null; + + while (Date.now() - start < timeoutMs) { + const row = await readOutbox(paymentId); + if (row && (!last || row.status !== last.status || row.attempts !== last.attempts)) { + emit({ type: 'outbox', ...row }); + last = row; + } + + if (row?.status === 'published') { + return row; + } + + await sleep(900); + } + + return last; +} + +function startWatcher({ res, country, paymentIdRef, acceptDlt, onKafkaEvent }) { + const children = []; + const seen = new Set(); + + for (const suffix of TOPIC_SUFFIXES) { + const topicName = topic(country, suffix); + const child = spawn('docker', [ + 'exec', + KAFKA_CONTAINER, + '/opt/kafka/bin/kafka-console-consumer.sh', + '--bootstrap-server', + 'localhost:9092', + '--topic', + topicName, + '--timeout-ms', + '90000', + ]); + + children.push(child); + let buffer = ''; + + child.stdout.on('data', (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let parsed = null; + try { + parsed = JSON.parse(trimmed); + } catch { + parsed = { raw: trimmed }; + } + + const eventPaymentId = paymentIdFromEvent(parsed); + const isRelevantPayment = paymentIdRef.value && eventPaymentId === paymentIdRef.value; + const isRelevantDlt = acceptDlt && topicName.endsWith('.dlt'); + + if (!isRelevantPayment && !isRelevantDlt) { + continue; + } + + const key = `${topicName}:${trimmed}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + + writeEvent(res, { + type: 'kafka-event', + topic: topicName, + level: topicName.endsWith('.dlt') ? 'error' : 'ok', + message: `Kafka event on ${topicName}`, + event: parsed, + }); + onKafkaEvent?.({ topic: topicName, suffix, event: parsed, raw: trimmed }); + + const step = suffix.includes('dlt') + ? 'dlt' + : suffix === 'payment.created.v1' + ? 'created' + : suffix === 'fraud.assessed.v1' + ? 'fraud' + : suffix === 'ledger.posted.v1' + ? 'ledger' + : suffix === 'payment.settled.v1' || suffix === 'payment.failed.v1' + ? 'saga' + : ''; + + if (step) { + writeEvent(res, { type: 'step', step, status: topicName.endsWith('.dlt') ? 'failed' : 'ok' }); + } + } + }); + } + + return () => { + for (const child of children) { + if (!child.killed) { + child.kill('SIGTERM'); + } + } + }; +} + +async function createPayment({ country, walletId, amount, currency, emit }) { + const payload = { + walletId, + countryCode: country.toUpperCase(), + amount: Number(amount), + currency, + }; + + emit({ type: 'log', source: 'api', level: 'info', message: `POST /payments ${JSON.stringify(payload)}` }); + + const response = await fetch(`${PAYMENT_API_URL}/payments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const body = await response.json(); + if (!response.ok || !body.paymentId) { + throw new Error(`Payment API rejected request: ${JSON.stringify(body)}`); + } + + emit({ type: 'log', source: 'api', level: 'ok', message: `Payment created: ${body.paymentId}` }); + emit({ type: 'step', step: 'api', status: 'ok' }); + return body.paymentId; +} + +async function readStatus(paymentId) { + const response = await fetch(`${PAYMENT_API_URL}/payments/${paymentId}/status`); + return response.json(); +} + +async function pollStatus({ paymentId, timeoutMs, emit, expectedFinal = true }) { + const start = Date.now(); + let lastStatus = ''; + + while (Date.now() - start < timeoutMs) { + const status = await readStatus(paymentId); + lastStatus = status.status; + emit({ type: 'status', level: 'info', message: `Payment status: ${status.status}`, status }); + + if (expectedFinal && (status.status === 'settled' || status.status === 'failed')) { + emit({ + type: 'step', + step: 'saga', + status: status.status === 'settled' ? 'ok' : 'failed', + }); + return status; + } + + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + + return { status: lastStatus || 'timeout' }; +} + +async function publishInvalidPayload(country, emit) { + const topicName = topic(country, 'payment.created.v1'); + const child = spawn('docker', [ + 'exec', + '-i', + KAFKA_CONTAINER, + '/opt/kafka/bin/kafka-console-producer.sh', + '--bootstrap-server', + 'localhost:9092', + '--topic', + topicName, + ]); + + const malformed = `not-json-${Date.now()}`; + emit({ type: 'log', source: 'kafka', level: 'warn', message: `Publishing malformed payload to ${topicName}` }); + child.stdin.write(`${malformed}\n`); + child.stdin.end(); + + await new Promise((resolve) => child.on('close', resolve)); + emit({ type: 'step', step: 'created', status: 'failed' }); +} + +async function publishPayload(topicName, payload, emit, label = 'payload') { + const child = spawn('docker', [ + 'exec', + '-i', + KAFKA_CONTAINER, + '/opt/kafka/bin/kafka-console-producer.sh', + '--bootstrap-server', + 'localhost:9092', + '--topic', + topicName, + ]); + + emit({ type: 'log', source: 'kafka', level: 'warn', message: `Publishing ${label} to ${topicName}` }); + child.stdin.write(`${payload}\n`); + child.stdin.end(); + + await new Promise((resolve) => child.on('close', resolve)); +} + +async function runScenario(req, res) { + const body = await parseBody(req); + const scenario = body.scenario || 'success'; + let country = normalizeCountry(body.country); + if (scenario === 'mx-outage-pe-available') { + country = 'pe'; + } + const paymentIdRef = { value: '' }; + const amount = scenario === 'fraud-rejected' ? 1500 : Number(body.amount || 25); + const currency = body.currency || (country === 'mx' ? 'MXN' : 'PEN'); + const walletId = body.walletId || `visual-${country}-${Date.now()}`; + const restore = []; + const emit = (event) => writeEvent(res, event); + const captured = { + paymentCreatedRaw: '', + paymentCreatedEventId: '', + fraudEventId: '', + ledgerEventId: '', + }; + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + emit({ type: 'hello', message: 'Visual demo scenario started' }); + emit(phoneState('processing', 'Procesando tu yape', 'Estamos creando el pago y esperando confirmacion de los servicios.', 'none')); + emit(narrative('Esta demo sigue un pago real: primero API y outbox, luego Kafka, fraude, ledger y saga.')); + const stopWatchers = startWatcher({ + res, + country, + paymentIdRef, + acceptDlt: scenario === 'dlt-invalid-payload', + onKafkaEvent: ({ suffix, event, raw }) => { + if (suffix === 'payment.created.v1') { + captured.paymentCreatedRaw = raw; + captured.paymentCreatedEventId = event.eventId || ''; + emit(serviceEvent('kafka', 'ok', `published ${event.type || suffix}`)); + emit(serviceEvent('relay', 'ok', 'relay published the outbox event to Kafka')); + } + if (suffix === 'fraud.assessed.v1') { + captured.fraudEventId = event.eventId || ''; + const approved = event?.payload?.approved; + emit(serviceEvent('fraud', approved ? 'ok' : 'failed', approved ? 'approved by risk rules' : `rejected: ${event?.payload?.reason || 'risk rule'}`)); + emit(narrative(approved ? 'FraudConsumer respondio OK. Es independiente del ledger y puede reprocesar sin doble efecto.' : 'FraudConsumer rechazo el pago. La saga debe cerrar failed sin esperar un falso settled.', approved ? 'ok' : 'warn')); + } + if (suffix === 'ledger.posted.v1') { + captured.ledgerEventId = event.eventId || ''; + emit(serviceEvent('ledger', 'ok', `entryId=${event?.payload?.entryId || 'created'}`)); + emit(narrative('LedgerConsumer confirmo la escritura. En produccion aqui viviria la doble entrada contable.', 'ok')); + } + if (suffix === 'payment.settled.v1') { + emit(serviceEvent('saga', 'ok', 'fraud + ledger succeeded')); + emit(narrative('StatusSaga vio ambos ACKs y cerro el pago como settled.', 'ok')); + } + if (suffix === 'payment.failed.v1') { + emit(serviceEvent('saga', 'failed', event?.payload?.reason || 'consumer failure')); + emit(narrative('StatusSaga cerro failed porque uno de los pasos criticos fallo.', 'warn')); + } + if (suffix.includes('dlt')) { + emit(serviceEvent('dlt', 'failed', event?.reason || 'retry budget exhausted')); + emit(narrative('El mensaje agoto retries y fue a DLT. No se borro silenciosamente.', 'warn')); + } + }, + }); + await sleep(1000); + + try { + emit({ type: 'log', source: 'scenario', level: 'info', message: `Scenario: ${scenario} / country: ${country}` }); + emit(serviceEvent('api', 'running', 'creating payment request')); + emit(serviceEvent('outbox', 'running', 'waiting for local transaction')); + + if (scenario === 'country-isolation') { + const blockedCountry = country === 'pe' ? 'mx' : 'pe'; + emit(narrative(`Aislaremos ${blockedCountry.toUpperCase()} apagando su ledger. El pago se hara en ${country.toUpperCase()} y debe seguir normal.`, 'warn')); + await stopCountryService(blockedCountry, 'ledger-consumer'); + restore.push(() => startCountryService(blockedCountry, 'ledger-consumer')); + emit({ + type: 'country-status', + country: blockedCountry, + availability: 'degraded', + detail: 'ledger-consumer stopped for isolation demo', + }); + emit({ type: 'log', source: 'country', level: 'warn', message: `${blockedCountry} ledger stopped; ${country} should keep processing.` }); + } + + if (scenario === 'mx-outage-pe-available') { + emit(narrative('Simularemos caida critica de MX: ledger y status-saga de MX se apagan. PE debe seguir yapeando.', 'warn')); + await stopCountryService('mx', 'ledger-consumer'); + restore.push(() => startCountryService('mx', 'ledger-consumer')); + await stopCountryService('mx', 'status-saga'); + restore.push(() => startCountryService('mx', 'status-saga')); + emit({ + type: 'country-status', + country: 'mx', + availability: 'degraded', + detail: 'MX ledger-consumer and status-saga stopped', + }); + emit({ + type: 'country-status', + country: 'pe', + availability: 'available', + detail: 'PE critical workers remain available', + }); + emit({ type: 'log', source: 'country', level: 'warn', message: 'MX critical workers stopped; PE payment should still settle.' }); + } + + if (scenario === 'ledger-down-recovery' || scenario === 'timeout-pending') { + emit({ type: 'log', source: 'ledger', level: 'warn', message: `Stopping ledger-consumer for ${country}` }); + await stopCountryService(country, 'ledger-consumer'); + restore.push(() => startCountryService(country, 'ledger-consumer')); + emit({ type: 'step', step: 'ledger', status: 'retrying' }); + emit(serviceEvent('ledger', 'retrying', 'consumer stopped; Kafka keeps the message')); + emit(narrative('Ledger esta caido. El pago no se pierde: Kafka conserva el evento y el status debe seguir pending.', 'warn')); + } + + if (scenario === 'dlt-invalid-payload') { + await publishInvalidPayload(country, emit); + await emitDbSnapshot({ + paymentId: '', + stage: 'DLT direct Kafka injection', + note: 'No se creo payment ni outbox porque este caso salta la API para probar DLT con payload invalido.', + emit, + }); + await sleep(8000); + emit(phoneState('failed', 'Error tecnico controlado', 'El mensaje agoto retries y fue enviado al DLT para investigacion.', 'reset')); + emit({ type: 'summary', level: 'error', message: 'Malformed event should appear in payment.created.v1.dlt.' }); + return; + } + + paymentIdRef.value = await createPayment({ country, walletId, amount, currency, emit }); + emit(serviceEvent('api', 'ok', `paymentId=${paymentIdRef.value}`)); + await emitDbSnapshot({ + paymentId: paymentIdRef.value, + stage: 'After local DB transaction', + note: 'PaymentService ya hizo commit de payment + outbox. Kafka todavia no fue llamado dentro de esa transaccion.', + emit, + }); + emit(serviceEvent('outbox', 'running', 'claiming outbox row')); + await pollOutbox({ paymentId: paymentIdRef.value, emit }); + await emitDbSnapshot({ + paymentId: paymentIdRef.value, + stage: 'After outbox relay', + note: 'El relay, en otro proceso, publico el evento y marco el outbox como published.', + emit, + }); + + if (scenario === 'ledger-down-recovery') { + await sleep(8000); + const pending = await readStatus(paymentIdRef.value); + emit({ type: 'status', level: 'warn', message: 'Ledger is down, status should still be pending.', status: pending }); + await emitDbSnapshot({ + paymentId: paymentIdRef.value, + stage: 'While ledger is down', + note: 'Fraud puede estar listo, pero ledger sigue pendiente; por eso el payment no debe mentir con settled.', + emit, + }); + emit(phoneState('pending', 'Tu yape esta en proceso', 'Ledger esta temporalmente caido; el pago queda pendiente sin perderse.', 'reset')); + emit({ type: 'log', source: 'ledger', level: 'info', message: `Restarting ledger-consumer for ${country}` }); + await startCountryService(country, 'ledger-consumer'); + restore.pop(); + emit({ type: 'step', step: 'ledger', status: 'running' }); + emit(serviceEvent('ledger', 'running', 'consumer restored; waiting for redelivery')); + const finalStatus = await pollStatus({ paymentId: paymentIdRef.value, timeoutMs: 60000, emit }); + await emitDbSnapshot({ + paymentId: paymentIdRef.value, + stage: 'After ledger recovery', + note: 'Al volver ledger, Kafka entrega el pendiente y la saga puede cerrar el pago.', + emit, + }); + emit(phoneState( + finalStatus.status === 'settled' ? 'success' : 'failed', + finalStatus.status === 'settled' ? 'Yape exitoso' : 'No pudimos completar tu yape', + finalStatus.status === 'settled' ? 'El pago pendiente se recupero y fue liquidado correctamente.' : 'El pago no pudo cerrarse luego de la recuperacion.', + 'reset', + )); + emit({ type: 'summary', level: finalStatus.status === 'settled' ? 'ok' : 'error', message: `Final status: ${finalStatus.status}` }); + return; + } + + if (scenario === 'timeout-pending') { + const pending = await pollStatus({ paymentId: paymentIdRef.value, timeoutMs: 16000, emit, expectedFinal: false }); + await emitDbSnapshot({ + paymentId: paymentIdRef.value, + stage: 'Timeout window ended', + note: 'La consulta de estado es honestamente eventual: muestra pending mientras falta un ACK critico.', + emit, + }); + emit(phoneState('pending', 'Tu yape esta en proceso', 'Aun esperamos confirmacion de un servicio critico. El estado sigue pending honestamente.', 'reset')); + emit({ + type: 'summary', + level: 'warn', + message: `Timeout window ended with status: ${pending.status}. This demonstrates honest eventual consistency.`, + }); + return; + } + + const finalStatus = await pollStatus({ paymentId: paymentIdRef.value, timeoutMs: 60000, emit }); + await emitDbSnapshot({ + paymentId: paymentIdRef.value, + stage: `Final DB state: ${finalStatus.status}`, + note: 'Estado persistido final despues de que la saga reconciliara los ACKs de fraud y ledger.', + emit, + }); + emit(phoneState( + finalStatus.status === 'settled' ? 'success' : 'failed', + finalStatus.status === 'settled' ? 'Yape exitoso' : 'No pudimos completar tu yape', + finalStatus.status === 'settled' ? 'Tu pago fue procesado y liquidado correctamente.' : 'El pago fue rechazado o fallo durante la liquidacion.', + 'reset', + )); + + if (scenario === 'idempotency-replay') { + emit(narrative('Ahora republicamos el mismo payment.created.v1 con el mismo eventId. Fraud y ledger deben ignorarlo.', 'warn')); + await publishPayload(topic(country, 'payment.created.v1'), captured.paymentCreatedRaw, emit, 'duplicate payment.created.v1'); + await sleep(5000); + const rows = await processedRows(country, [ + captured.paymentCreatedEventId, + captured.fraudEventId, + captured.ledgerEventId, + ].filter(Boolean)); + + emit({ + type: 'idempotency', + level: 'ok', + message: `Replay enviado. processed_events mantiene una fila por consumer/eventId (${rows.length} marcas encontradas).`, + fraud: 'duplicate payment.created ignored by fraud-consumer', + ledger: 'duplicate payment.created ignored by ledger-consumer', + rows, + }); + await emitDbSnapshot({ + paymentId: paymentIdRef.value, + stage: 'After duplicate replay', + note: 'processed_events mantiene una marca por consumer/eventId; no se crea otro efecto observable.', + emit, + }); + emit(narrative('Este es el punto clave de redelivery: repetir el evento no genera otro scoring ni otro asiento ledger.', 'ok')); + } + + emit({ type: 'summary', level: finalStatus.status === 'settled' ? 'ok' : 'error', message: `Final status: ${finalStatus.status}` }); + } catch (error) { + emit(phoneState('failed', 'No pudimos completar tu yape', error instanceof Error ? error.message : String(error), 'retry')); + emit({ + type: 'summary', + level: 'error', + message: error instanceof Error ? error.message : String(error), + }); + } finally { + for (const action of restore.reverse()) { + try { + await action(); + emit({ type: 'log', source: 'cleanup', level: 'ok', message: 'Service restored for the next demo.' }); + } catch (error) { + emit({ type: 'log', source: 'cleanup', level: 'error', message: error instanceof Error ? error.message : String(error) }); + } + } + + setTimeout(() => { + stopWatchers(); + emit({ type: 'done', message: 'Scenario finished' }); + res.end(); + }, 1200); + } +} + +function serveStatic(req, res) { + const url = new URL(req.url, `http://${req.headers.host}`); + const requestedPath = url.pathname === '/' ? '/index.html' : url.pathname; + const filePath = path.normalize(path.join(PUBLIC_DIR, requestedPath)); + + if (!filePath.startsWith(PUBLIC_DIR)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + fs.readFile(filePath, (error, content) => { + if (error) { + res.writeHead(404); + res.end('Not found'); + return; + } + + res.writeHead(200, { 'Content-Type': CONTENT_TYPES[path.extname(filePath)] || 'text/plain; charset=utf-8' }); + res.end(content); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + try { + if (req.method === 'GET' && url.pathname === '/api/health') { + sendJson(res, 200, await health()); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/scenario') { + await runScenario(req, res); + return; + } + + if (req.method === 'GET') { + serveStatic(req, res); + return; + } + + sendJson(res, 405, { error: 'method_not_allowed' }); + } catch (error) { + sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) }); + } +}); + +server.listen(PORT, () => { + console.log(`Visual demo listening on http://localhost:${PORT}`); +}); diff --git a/test/dlt-handler.spec.ts b/test/dlt-handler.spec.ts new file mode 100644 index 00000000..70b7ab6c --- /dev/null +++ b/test/dlt-handler.spec.ts @@ -0,0 +1,28 @@ +import { ConsumerExecutorService } from 'src/domain/services/consumer-executor.service'; +import { FakeDeadLetterPublisher } from 'test/helpers/fakes'; + +describe('DLT handler', () => { + it('sends event to DLT after retry budget is exhausted', async () => { + const dltPublisher = new FakeDeadLetterPublisher(); + const service = new ConsumerExecutorService(dltPublisher); + + let attempts = 0; + await service.executeWithRetry( + { + consumerName: 'fraud-consumer', + sourceTopic: 'payment.created.v1', + eventId: 'evt-1', + payload: JSON.stringify({ eventId: 'evt-1' }), + }, + 3, + async () => { + attempts += 1; + throw new Error('simulated_failure'); + }, + ); + + expect(attempts).toBe(3); + expect(dltPublisher.dlt).toHaveLength(1); + expect(dltPublisher.dlt[0].originalTopic).toBe('payment.created.v1'); + }); +}); diff --git a/test/helpers/fakes.ts b/test/helpers/fakes.ts new file mode 100644 index 00000000..2c2f7661 --- /dev/null +++ b/test/helpers/fakes.ts @@ -0,0 +1,221 @@ +import { Payment, PaymentStatus } from 'src/domain/entities/payment'; +import { OutboxEvent, OutboxStatus } from 'src/domain/entities/outbox-event'; +import { PaymentStep, StepStatus } from 'src/domain/entities/payment-step'; +import { EnqueueOutboxEventParams, OutboxRepository } from 'src/domain/repositories/outbox.repository'; +import { CreatePaymentParams, PaymentRepository } from 'src/domain/repositories/payment.repository'; +import { PaymentStepRepository } from 'src/domain/repositories/payment-step.repository'; +import { ProcessedEventRepository } from 'src/domain/repositories/processed-event.repository'; +import { DeadLetterPublisher, EventPublisher } from 'src/domain/repositories/ports'; +import { UnitOfWork } from 'src/domain/repositories/unit-of-work'; + +export class FakePaymentRepository implements PaymentRepository { + public readonly items = new Map(); + + async createPending(params: CreatePaymentParams): Promise { + const now = new Date(); + const payment: Payment = { + id: params.id, + walletId: params.walletId, + countryCode: params.countryCode, + amount: params.amount, + currency: params.currency, + status: PaymentStatus.Pending, + createdAt: now, + updatedAt: now, + }; + + this.items.set(params.id, payment); + return payment; + } + + async updateStatus(paymentId: string, status: PaymentStatus): Promise { + const payment = this.items.get(paymentId); + if (!payment) { + return; + } + + payment.status = status; + payment.updatedAt = new Date(); + this.items.set(paymentId, payment); + } + + async getById(paymentId: string): Promise { + return this.items.get(paymentId) ?? null; + } +} + +export class FakePaymentStepRepository implements PaymentStepRepository { + public readonly items = new Map(); + + async init(paymentId: string): Promise { + this.items.set(paymentId, { + paymentId, + fraudStatus: StepStatus.Pending, + ledgerStatus: StepStatus.Pending, + failureReason: null, + updatedAt: new Date(), + }); + } + + async markFraud(paymentId: string, status: StepStatus, reason?: string): Promise { + const step = this.items.get(paymentId); + if (!step) { + return; + } + + step.fraudStatus = status; + step.failureReason = status === StepStatus.Failed ? reason ?? 'fraud_failed' : null; + step.updatedAt = new Date(); + this.items.set(paymentId, step); + } + + async markLedger(paymentId: string, status: StepStatus, reason?: string): Promise { + const step = this.items.get(paymentId); + if (!step) { + return; + } + + step.ledgerStatus = status; + step.failureReason = status === StepStatus.Failed ? reason ?? 'ledger_failed' : step.failureReason; + step.updatedAt = new Date(); + this.items.set(paymentId, step); + } + + async getByPaymentId(paymentId: string): Promise { + return this.items.get(paymentId) ?? null; + } +} + +export class FakeOutboxRepository implements OutboxRepository { + public readonly items = new Map(); + public failEnqueue = false; + + async enqueue(params: EnqueueOutboxEventParams): Promise { + if (this.failEnqueue) { + throw new Error('outbox_enqueue_failed'); + } + + const now = new Date(); + this.items.set(params.id, { + id: params.id, + aggregateId: params.aggregateId, + eventId: params.eventId, + eventType: params.eventType, + topic: params.topic, + countryCode: params.countryCode, + payload: params.payload, + status: OutboxStatus.Pending, + attempts: 0, + nextAttemptAt: now, + publishedAt: null, + createdAt: now, + updatedAt: now, + }); + } + + async lockPendingBatch(limit: number, now: Date): Promise { + return Array.from(this.items.values()) + .filter((item) => item.status === OutboxStatus.Pending && item.nextAttemptAt <= now) + .slice(0, limit); + } + + async markPublished(outboxId: string, publishedAt: Date): Promise { + const event = this.items.get(outboxId); + if (!event) { + return; + } + + event.status = OutboxStatus.Published; + event.publishedAt = publishedAt; + event.attempts += 1; + this.items.set(outboxId, event); + } + + async markForRetry(outboxId: string, nextAttemptAt: Date): Promise { + const event = this.items.get(outboxId); + if (!event) { + return; + } + + event.status = OutboxStatus.Pending; + event.nextAttemptAt = nextAttemptAt; + event.attempts += 1; + this.items.set(outboxId, event); + } +} + +export class FakeProcessedEventRepository implements ProcessedEventRepository { + private readonly keys = new Set(); + + async tryMarkProcessed(consumerName: string, countryCode: string, eventId: string): Promise { + const key = `${consumerName}:${countryCode.toLowerCase()}:${eventId}`; + if (this.keys.has(key)) { + return false; + } + + this.keys.add(key); + return true; + } +} + +export class FakeUnitOfWork implements UnitOfWork { + constructor( + private readonly paymentRepository: FakePaymentRepository, + private readonly paymentStepRepository: FakePaymentStepRepository, + private readonly outboxRepository: FakeOutboxRepository, + ) {} + + async execute(work: () => Promise): Promise { + const paymentSnapshot = new Map(this.paymentRepository.items); + const stepSnapshot = new Map(this.paymentStepRepository.items); + const outboxSnapshot = new Map(this.outboxRepository.items); + + try { + return await work(); + } catch (error) { + this.paymentRepository.items.clear(); + this.paymentStepRepository.items.clear(); + this.outboxRepository.items.clear(); + + for (const [key, value] of paymentSnapshot) { + this.paymentRepository.items.set(key, value); + } + + for (const [key, value] of stepSnapshot) { + this.paymentStepRepository.items.set(key, value); + } + + for (const [key, value] of outboxSnapshot) { + this.outboxRepository.items.set(key, value); + } + + throw error; + } + } +} + +export class FakeEventPublisher implements EventPublisher { + public readonly published: Array<{ topic: string; payload: string; key?: string }> = []; + + async publish(topic: string, payload: string, key?: string): Promise { + this.published.push({ topic, payload, key }); + } +} + +export class FakeDeadLetterPublisher implements DeadLetterPublisher { + public readonly dlt: Array<{ + originalTopic: string; + payload: string; + reason: string; + sourceEventId: string; + }> = []; + + async publishDeadLetter( + originalTopic: string, + payload: string, + reason: string, + sourceEventId: string, + ): Promise { + this.dlt.push({ originalTopic, payload, reason, sourceEventId }); + } +} diff --git a/test/idempotency-consumers.spec.ts b/test/idempotency-consumers.spec.ts new file mode 100644 index 00000000..f7d974bf --- /dev/null +++ b/test/idempotency-consumers.spec.ts @@ -0,0 +1,72 @@ +import { EventTypes } from 'src/domain/events/event-types'; +import { FraudConsumerService } from 'src/domain/services/fraud-consumer.service'; +import { LedgerConsumerService } from 'src/domain/services/ledger-consumer.service'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; +import { FakeEventPublisher, FakeProcessedEventRepository } from 'test/helpers/fakes'; + +const paymentCreatedEvent = JSON.stringify({ + eventId: 'evt-100', + type: EventTypes.PaymentCreatedV1, + aggregateId: 'payment-1', + occurredAt: new Date().toISOString(), + countryCode: 'PE', + schemaVersion: 1, + payload: { + paymentId: 'payment-1', + walletId: 'wallet-1', + amount: 100, + currency: 'PEN', + }, +}); + +const paymentCreatedEventMxSameId = JSON.stringify({ + eventId: 'evt-100', + type: EventTypes.PaymentCreatedV1, + aggregateId: 'payment-2', + occurredAt: new Date().toISOString(), + countryCode: 'MX', + schemaVersion: 1, + payload: { + paymentId: 'payment-2', + walletId: 'wallet-2', + amount: 150, + currency: 'MXN', + }, +}); + +describe('Idempotent consumers', () => { + it('fraud consumer should not publish duplicate side effects', async () => { + const processed = new FakeProcessedEventRepository(); + const publisher = new FakeEventPublisher(); + const service = new FraudConsumerService(processed, publisher, new TopicResolver(false)); + + await service.handlePaymentCreated(paymentCreatedEvent); + await service.handlePaymentCreated(paymentCreatedEvent); + + expect(publisher.published).toHaveLength(1); + }); + + it('ledger consumer should not publish duplicate side effects', async () => { + const processed = new FakeProcessedEventRepository(); + const publisher = new FakeEventPublisher(); + const service = new LedgerConsumerService(processed, publisher, new TopicResolver(false)); + + await service.handlePaymentCreated(paymentCreatedEvent); + await service.handlePaymentCreated(paymentCreatedEvent); + + expect(publisher.published).toHaveLength(1); + }); + + it('fraud consumer should process same eventId for different countries independently', async () => { + const processed = new FakeProcessedEventRepository(); + const publisher = new FakeEventPublisher(); + const service = new FraudConsumerService(processed, publisher, new TopicResolver(true)); + + await service.handlePaymentCreated(paymentCreatedEvent); + await service.handlePaymentCreated(paymentCreatedEventMxSameId); + + expect(publisher.published).toHaveLength(2); + expect(publisher.published[0].topic).toBe('pe.payments.fraud.assessed.v1'); + expect(publisher.published[1].topic).toBe('mx.payments.fraud.assessed.v1'); + }); +}); diff --git a/test/payment-service.spec.ts b/test/payment-service.spec.ts new file mode 100644 index 00000000..c8c759c6 --- /dev/null +++ b/test/payment-service.spec.ts @@ -0,0 +1,55 @@ +import { PaymentStatus } from 'src/domain/entities/payment'; +import { PaymentService } from 'src/domain/services/payment.service'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; +import { + FakeOutboxRepository, + FakePaymentRepository, + FakePaymentStepRepository, + FakeUnitOfWork, +} from 'test/helpers/fakes'; + +describe('PaymentService', () => { + it('stores payment and outbox in one local transaction', async () => { + const payments = new FakePaymentRepository(); + const steps = new FakePaymentStepRepository(); + const outbox = new FakeOutboxRepository(); + const uow = new FakeUnitOfWork(payments, steps, outbox); + + const service = new PaymentService(uow, payments, steps, outbox, new TopicResolver(false)); + + const result = await service.createPayment({ + walletId: 'wallet-01', + countryCode: 'PE', + amount: 250, + currency: 'PEN', + }); + + expect(result.status).toBe(PaymentStatus.Pending); + expect(payments.items.size).toBe(1); + expect(steps.items.size).toBe(1); + expect(outbox.items.size).toBe(1); + }); + + it('rolls back payment write when outbox enqueue fails', async () => { + const payments = new FakePaymentRepository(); + const steps = new FakePaymentStepRepository(); + const outbox = new FakeOutboxRepository(); + outbox.failEnqueue = true; + + const uow = new FakeUnitOfWork(payments, steps, outbox); + const service = new PaymentService(uow, payments, steps, outbox, new TopicResolver(false)); + + await expect( + service.createPayment({ + walletId: 'wallet-01', + countryCode: 'PE', + amount: 250, + currency: 'PEN', + }), + ).rejects.toThrow('outbox_enqueue_failed'); + + expect(payments.items.size).toBe(0); + expect(steps.items.size).toBe(0); + expect(outbox.items.size).toBe(0); + }); +}); diff --git a/test/status-saga.spec.ts b/test/status-saga.spec.ts new file mode 100644 index 00000000..4963177f --- /dev/null +++ b/test/status-saga.spec.ts @@ -0,0 +1,97 @@ +import { PaymentStatus } from 'src/domain/entities/payment'; +import { EventTypes } from 'src/domain/events/event-types'; +import { StatusSagaService } from 'src/domain/services/status-saga.service'; +import { TopicResolver } from 'src/domain/services/topic-resolver'; +import { + FakeEventPublisher, + FakePaymentRepository, + FakePaymentStepRepository, + FakeProcessedEventRepository, +} from 'test/helpers/fakes'; + +describe('StatusSagaService', () => { + it('marks payment as settled when both downstream consumers succeed', async () => { + const processed = new FakeProcessedEventRepository(); + const payments = new FakePaymentRepository(); + const steps = new FakePaymentStepRepository(); + const publisher = new FakeEventPublisher(); + + await payments.createPending({ + id: 'payment-1', + walletId: 'wallet-1', + countryCode: 'PE', + amount: 20, + currency: 'PEN', + }); + await steps.init('payment-1'); + + const service = new StatusSagaService(processed, payments, steps, publisher, new TopicResolver(false)); + + await service.onFraudAssessed( + JSON.stringify({ + eventId: 'evt-fraud', + type: EventTypes.FraudAssessedV1, + aggregateId: 'payment-1', + countryCode: 'PE', + payload: { + paymentId: 'payment-1', + approved: true, + }, + }), + ); + + await service.onLedgerPosted( + JSON.stringify({ + eventId: 'evt-ledger', + type: EventTypes.LedgerPostedV1, + aggregateId: 'payment-1', + countryCode: 'PE', + payload: { + paymentId: 'payment-1', + success: true, + entryId: 'entry-1', + }, + }), + ); + + const payment = await payments.getById('payment-1'); + expect(payment?.status).toBe(PaymentStatus.Settled); + expect(publisher.published.some((event) => event.topic === EventTypes.PaymentSettledV1)).toBe(true); + }); + + it('marks payment as failed when one step fails', async () => { + const processed = new FakeProcessedEventRepository(); + const payments = new FakePaymentRepository(); + const steps = new FakePaymentStepRepository(); + const publisher = new FakeEventPublisher(); + + await payments.createPending({ + id: 'payment-2', + walletId: 'wallet-2', + countryCode: 'PE', + amount: 1200, + currency: 'PEN', + }); + await steps.init('payment-2'); + + const service = new StatusSagaService(processed, payments, steps, publisher, new TopicResolver(false)); + + await service.onFraudAssessed( + JSON.stringify({ + eventId: 'evt-fraud-2', + type: EventTypes.FraudAssessedV1, + aggregateId: 'payment-2', + countryCode: 'PE', + payload: { + paymentId: 'payment-2', + approved: false, + reason: 'risk_threshold_exceeded', + }, + }), + ); + + const payment = await payments.getById('payment-2'); + expect(payment?.status).toBe(PaymentStatus.Failed); + expect(publisher.published.some((event) => event.topic === EventTypes.PaymentFailedV1)).toBe(true); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..c0972e5f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true + }, + "exclude": ["test", "**/*.spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..3f125fc2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2021", + "moduleResolution": "node", + "strict": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "outDir": "dist", + "baseUrl": ".", + "paths": { + "src/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/visual-yape.png b/visual-yape.png new file mode 100644 index 00000000..64188ca6 Binary files /dev/null and b/visual-yape.png differ