create-easy-stack 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create.mjs +244 -0
- package/infra/email/files/src/core/config/email.env.ts +73 -0
- package/infra/email/files/src/core/infrastructure/email/email-dispatcher.port.ts +13 -0
- package/infra/email/files/src/core/infrastructure/email/email-layout.spec.ts +83 -0
- package/infra/email/files/src/core/infrastructure/email/email-layout.ts +88 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.factory.spec.ts +87 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.factory.ts +35 -0
- package/infra/email/files/src/core/infrastructure/email/email-sender.port.ts +19 -0
- package/infra/email/files/src/core/infrastructure/email/email.job.spec.ts +101 -0
- package/infra/email/files/src/core/infrastructure/email/email.job.ts +64 -0
- package/infra/email/files/src/core/infrastructure/email/email.module.spec.ts +60 -0
- package/infra/email/files/src/core/infrastructure/email/email.module.ts +34 -0
- package/infra/email/files/src/core/infrastructure/email/log-email-sender.spec.ts +34 -0
- package/infra/email/files/src/core/infrastructure/email/log-email-sender.ts +17 -0
- package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.spec.ts +99 -0
- package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.ts +63 -0
- package/infra/email/infra.json +58 -0
- package/infra/prisma-postgres/docker.service.yml +12 -0
- package/infra/prisma-postgres/files/prisma/schema.prisma +10 -0
- package/infra/prisma-postgres/files/prisma.config.mjs +10 -0
- package/infra/prisma-postgres/files/src/core/config/database.env.ts +13 -0
- package/infra/prisma-postgres/files/src/core/infrastructure/database/database.module.ts +9 -0
- package/infra/prisma-postgres/files/src/core/infrastructure/database/prisma.service.ts +26 -0
- package/infra/prisma-postgres/infra.json +60 -0
- package/infra/rabbitmq/docker.service.yml +16 -0
- package/infra/rabbitmq/files/src/core/config/messaging.env.ts +29 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.spec.ts +60 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.ts +33 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.spec.ts +23 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.ts +19 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.spec.ts +80 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.ts +66 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.boundary.spec.ts +34 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.factory.ts +21 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.spec.ts +75 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.ts +30 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.port.ts +59 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.spec.ts +510 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.ts +353 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.module.ts +9 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.spec.ts +289 -0
- package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.ts +207 -0
- package/infra/rabbitmq/infra.json +55 -0
- package/infra/redis/docker.service.yml +10 -0
- package/infra/redis/files/src/core/http/decorators/rate-limit.decorator.ts +26 -0
- package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.spec.ts +180 -0
- package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.ts +105 -0
- package/infra/redis/files/src/core/infrastructure/redis/cache.constants.ts +67 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.spec.ts +146 -0
- package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.ts +131 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.integration-spec.ts +111 -0
- package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.ts +82 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.module.ts +15 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.spec.ts +45 -0
- package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.ts +38 -0
- package/infra/redis/files/src/core/infrastructure/redis/redis.module.ts +9 -0
- package/infra/redis/files/src/core/infrastructure/redis/redis.service.ts +196 -0
- package/infra/redis/infra.json +53 -0
- package/infra/storage-cloudinary/files/src/core/config/storage.env.ts +43 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.spec.ts +275 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.ts +158 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.factory.ts +30 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.module.ts +17 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.port.ts +38 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.spec.ts +48 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.ts +25 -0
- package/infra/storage-cloudinary/files/src/core/infrastructure/storage/unconfigured-image-storage.ts +19 -0
- package/infra/storage-cloudinary/infra.json +73 -0
- package/lib/fs-utils.mjs +115 -0
- package/lib/registry.mjs +50 -0
- package/lib/scaffold.mjs +157 -0
- package/package.json +37 -0
- package/templates/nest-ddd/files/.env.example +5 -0
- package/templates/nest-ddd/files/.husky/commit-msg +1 -0
- package/templates/nest-ddd/files/.husky/pre-commit +1 -0
- package/templates/nest-ddd/files/.oxlintrc.json +10 -0
- package/templates/nest-ddd/files/.prettierrc +4 -0
- package/templates/nest-ddd/files/_gitignore +40 -0
- package/templates/nest-ddd/files/commitlint.config.js +3 -0
- package/templates/nest-ddd/files/nest-cli.json +8 -0
- package/templates/nest-ddd/files/package.json +66 -0
- package/templates/nest-ddd/files/plop-templates/command/command.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/command/handler.ts.hbs +30 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.command.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.spec.ts.hbs +35 -0
- package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.ts.hbs +25 -0
- package/templates/nest-ddd/files/plop-templates/module/application/index.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.handler.ts.hbs +29 -0
- package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.query.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/entities/{{kebab}}.entity.ts.hbs +37 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/events/{{kebab}}-created.event.ts.hbs +10 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/exceptions/{{kebab}}.exception.ts.hbs +11 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/index.ts.hbs +4 -0
- package/templates/nest-ddd/files/plop-templates/module/domain/repositories/{{kebab}}.repository.ts.hbs +7 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/controllers/{{kebab}}.controller.ts.hbs +53 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/create-{{kebab}}.dto.ts.hbs +10 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/{{kebab}}-response.dto.ts.hbs +15 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/index.ts.hbs +4 -0
- package/templates/nest-ddd/files/plop-templates/module/presentation/mappers/{{kebab}}.mapper.ts.hbs +13 -0
- package/templates/nest-ddd/files/plop-templates/module/{{kebab}}.module.ts.hbs +40 -0
- package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/database/repositories/in-memory-{{kebab}}.repository.ts.hbs +17 -0
- package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/index.ts.hbs +1 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/mappers/{{kebab}}.mapper.ts.hbs +29 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/repositories/prisma-{{kebab}}.repository.ts.hbs +28 -0
- package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/index.ts.hbs +2 -0
- package/templates/nest-ddd/files/plop-templates/query/handler.ts.hbs +28 -0
- package/templates/nest-ddd/files/plop-templates/query/query.ts.hbs +5 -0
- package/templates/nest-ddd/files/plop-templates/snippets/error-codes.hbs +14 -0
- package/templates/nest-ddd/files/plop-templates/snippets/prisma-model.hbs +7 -0
- package/templates/nest-ddd/files/plopfile.mjs +186 -0
- package/templates/nest-ddd/files/src/app.controller.ts +13 -0
- package/templates/nest-ddd/files/src/app.module.ts +26 -0
- package/templates/nest-ddd/files/src/core/config/app.config.ts +9 -0
- package/templates/nest-ddd/files/src/core/config/env.validation.ts +30 -0
- package/templates/nest-ddd/files/src/core/domain/base/aggregate-root.base.ts +18 -0
- package/templates/nest-ddd/files/src/core/domain/base/domain-event.base.ts +7 -0
- package/templates/nest-ddd/files/src/core/domain/base/entity.base.ts +36 -0
- package/templates/nest-ddd/files/src/core/domain/base/value-object.base.ts +17 -0
- package/templates/nest-ddd/files/src/core/domain/exceptions/domain.exception.ts +16 -0
- package/templates/nest-ddd/files/src/core/domain/exceptions/error-codes.ts +28 -0
- package/templates/nest-ddd/files/src/core/domain/types/pagination.ts +9 -0
- package/templates/nest-ddd/files/src/core/http/decorators/client-info.decorator.ts +24 -0
- package/templates/nest-ddd/files/src/core/http/decorators/current-user.decorator.ts +19 -0
- package/templates/nest-ddd/files/src/core/http/dtos/paginated-response.dto.ts +29 -0
- package/templates/nest-ddd/files/src/core/http/dtos/pagination-query.dto.ts +17 -0
- package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.spec.ts +163 -0
- package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.ts +97 -0
- package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.spec.ts +47 -0
- package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.ts +31 -0
- package/templates/nest-ddd/files/src/core/http/interceptors/logging.interceptor.ts +37 -0
- package/templates/nest-ddd/files/src/core/http/interceptors/response.interceptor.ts +53 -0
- package/templates/nest-ddd/files/src/core/http/logging/app.logger.ts +34 -0
- package/templates/nest-ddd/files/src/core/index.ts +33 -0
- package/templates/nest-ddd/files/src/core/utils/correlation-id.util.spec.ts +29 -0
- package/templates/nest-ddd/files/src/core/utils/correlation-id.util.ts +15 -0
- package/templates/nest-ddd/files/src/core/utils/hash.util.spec.ts +61 -0
- package/templates/nest-ddd/files/src/core/utils/hash.util.ts +47 -0
- package/templates/nest-ddd/files/src/core/utils/snowflake.util.spec.ts +40 -0
- package/templates/nest-ddd/files/src/core/utils/snowflake.util.ts +99 -0
- package/templates/nest-ddd/files/src/core/utils/string.util.spec.ts +20 -0
- package/templates/nest-ddd/files/src/core/utils/string.util.ts +21 -0
- package/templates/nest-ddd/files/src/core/utils/time.util.spec.ts +25 -0
- package/templates/nest-ddd/files/src/core/utils/time.util.ts +25 -0
- package/templates/nest-ddd/files/src/core/utils/ttl.util.ts +22 -0
- package/templates/nest-ddd/files/src/main.ts +61 -0
- package/templates/nest-ddd/files/test/setup.ts +6 -0
- package/templates/nest-ddd/files/tsconfig.build.json +9 -0
- package/templates/nest-ddd/files/tsconfig.json +28 -0
- package/templates/nest-ddd/files/vitest.config.e2e.ts +15 -0
- package/templates/nest-ddd/files/vitest.config.ts +16 -0
- package/templates/nest-ddd/template.json +8 -0
- package/templates/node-express/files/.env.example +28 -0
- package/templates/node-express/files/docker-compose.yml +52 -0
- package/templates/node-express/files/package-lock.json +11880 -0
- package/templates/node-express/files/package.json +43 -0
- package/templates/node-express/files/server.js +13 -0
- package/templates/node-express/files/src/app.js +72 -0
- package/templates/node-express/files/src/auth/authUtils.js +116 -0
- package/templates/node-express/files/src/auth/checkAuth.js +61 -0
- package/templates/node-express/files/src/bucket_pattern/comment.model.js +27 -0
- package/templates/node-express/files/src/bucket_pattern/comment.service.js +50 -0
- package/templates/node-express/files/src/config/config.aws-s3.js +28 -0
- package/templates/node-express/files/src/config/config.cloudinary.js +13 -0
- package/templates/node-express/files/src/config/config.mongodb.js +25 -0
- package/templates/node-express/files/src/config/config.multer.js +25 -0
- package/templates/node-express/files/src/config/config.nodemailer.js +16 -0
- package/templates/node-express/files/src/controllers/access.controller.js +49 -0
- package/templates/node-express/files/src/controllers/cart.controller.js +38 -0
- package/templates/node-express/files/src/controllers/checkout.controller.js +15 -0
- package/templates/node-express/files/src/controllers/comment.controller.js +29 -0
- package/templates/node-express/files/src/controllers/discount.controller.js +68 -0
- package/templates/node-express/files/src/controllers/email.controller.js +16 -0
- package/templates/node-express/files/src/controllers/inventory.controller.js +15 -0
- package/templates/node-express/files/src/controllers/notification.controller.js +15 -0
- package/templates/node-express/files/src/controllers/product.controller.js +175 -0
- package/templates/node-express/files/src/controllers/profile.controller.js +65 -0
- package/templates/node-express/files/src/controllers/rbac.controller.js +41 -0
- package/templates/node-express/files/src/controllers/template.controller.js +20 -0
- package/templates/node-express/files/src/controllers/upload.controller.js +56 -0
- package/templates/node-express/files/src/controllers/user.controller.js +36 -0
- package/templates/node-express/files/src/core/error.response.js +84 -0
- package/templates/node-express/files/src/core/success.response.js +44 -0
- package/templates/node-express/files/src/database/init.mongodb.level0.js +16 -0
- package/templates/node-express/files/src/database/init.mongodb.level1.js +36 -0
- package/templates/node-express/files/src/database/init.redis.js +119 -0
- package/templates/node-express/files/src/helpers/asyncHandler.js +7 -0
- package/templates/node-express/files/src/helpers/check.connect.js +29 -0
- package/templates/node-express/files/src/keys/private_key.pem +28 -0
- package/templates/node-express/files/src/keys/public_key.pem +9 -0
- package/templates/node-express/files/src/logger/discord.log.js +26 -0
- package/templates/node-express/files/src/logger/discord.log.v2.js +56 -0
- package/templates/node-express/files/src/logger/mylogger.log.js +130 -0
- package/templates/node-express/files/src/logger/winston.log.js +27 -0
- package/templates/node-express/files/src/middlewares/index.js +21 -0
- package/templates/node-express/files/src/middlewares/rbac.js +37 -0
- package/templates/node-express/files/src/middlewares/role.middleware.js +12 -0
- package/templates/node-express/files/src/models/KeyToken.model.js +37 -0
- package/templates/node-express/files/src/models/apiKey.model.js +31 -0
- package/templates/node-express/files/src/models/cart.model.js +47 -0
- package/templates/node-express/files/src/models/comment.model.js +43 -0
- package/templates/node-express/files/src/models/discount.model.js +92 -0
- package/templates/node-express/files/src/models/inventory.model.js +42 -0
- package/templates/node-express/files/src/models/notification.model.js +52 -0
- package/templates/node-express/files/src/models/order.model.js +68 -0
- package/templates/node-express/files/src/models/otp.model.js +32 -0
- package/templates/node-express/files/src/models/product.model.js +144 -0
- package/templates/node-express/files/src/models/repository/cart.repo.js +71 -0
- package/templates/node-express/files/src/models/repository/discount.repo.js +63 -0
- package/templates/node-express/files/src/models/repository/inventory.repo.js +45 -0
- package/templates/node-express/files/src/models/repository/product.repo.js +145 -0
- package/templates/node-express/files/src/models/repository/shop.repo.js +18 -0
- package/templates/node-express/files/src/models/repository/spu.repo.js +18 -0
- package/templates/node-express/files/src/models/repository/user.repo.js +20 -0
- package/templates/node-express/files/src/models/resource.model.js +32 -0
- package/templates/node-express/files/src/models/role.model.js +57 -0
- package/templates/node-express/files/src/models/shop.model.js +43 -0
- package/templates/node-express/files/src/models/sku.model.js +81 -0
- package/templates/node-express/files/src/models/spu.model.js +101 -0
- package/templates/node-express/files/src/models/template.model.js +25 -0
- package/templates/node-express/files/src/models/user.model.js +82 -0
- package/templates/node-express/files/src/postman/access.post.http +22 -0
- package/templates/node-express/files/src/routes/access/index.route.js +21 -0
- package/templates/node-express/files/src/routes/cart/index.route.js +20 -0
- package/templates/node-express/files/src/routes/checkout/index.route.js +10 -0
- package/templates/node-express/files/src/routes/comment/index.route.js +16 -0
- package/templates/node-express/files/src/routes/discount/index.route.js +26 -0
- package/templates/node-express/files/src/routes/index.route.js +27 -0
- package/templates/node-express/files/src/routes/inventory/index.route.js +11 -0
- package/templates/node-express/files/src/routes/notification/index.route.js +14 -0
- package/templates/node-express/files/src/routes/product/index.route.js +38 -0
- package/templates/node-express/files/src/routes/profile/index.route.js +15 -0
- package/templates/node-express/files/src/routes/rbac/index.route.js +21 -0
- package/templates/node-express/files/src/routes/template/index.route.js +11 -0
- package/templates/node-express/files/src/routes/upload/index.route.js +28 -0
- package/templates/node-express/files/src/routes/user/index.route.js +13 -0
- package/templates/node-express/files/src/services/access.service.js +257 -0
- package/templates/node-express/files/src/services/apiKey.service.js +15 -0
- package/templates/node-express/files/src/services/cart.service.js +115 -0
- package/templates/node-express/files/src/services/checkout.service.js +218 -0
- package/templates/node-express/files/src/services/comment.service.js +185 -0
- package/templates/node-express/files/src/services/discount.service.js +282 -0
- package/templates/node-express/files/src/services/email.service.js +71 -0
- package/templates/node-express/files/src/services/inventory.service.js +35 -0
- package/templates/node-express/files/src/services/keyToken.service.js +77 -0
- package/templates/node-express/files/src/services/notification.service.js +58 -0
- package/templates/node-express/files/src/services/otp.service.js +36 -0
- package/templates/node-express/files/src/services/product.service.js +228 -0
- package/templates/node-express/files/src/services/rbac.service.js +143 -0
- package/templates/node-express/files/src/services/redis.service.js +57 -0
- package/templates/node-express/files/src/services/redisPubSub.service.js +72 -0
- package/templates/node-express/files/src/services/shop.service.js +21 -0
- package/templates/node-express/files/src/services/sku.service.js +52 -0
- package/templates/node-express/files/src/services/spu.service.js +89 -0
- package/templates/node-express/files/src/services/template.service.js +41 -0
- package/templates/node-express/files/src/services/upload.s3.service.js +55 -0
- package/templates/node-express/files/src/services/upload.service.js +74 -0
- package/templates/node-express/files/src/services/user.service.js +92 -0
- package/templates/node-express/files/src/temp/1734420604996-ci-cd.png +0 -0
- package/templates/node-express/files/src/temp/1734421222213-cover-fb.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421222214-cover-fb-01.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421293437-cover-fb-01.jpg +0 -0
- package/templates/node-express/files/src/temp/1734421293437-cover-fb.jpg +0 -0
- package/templates/node-express/files/src/tests/inventory.test.js +21 -0
- package/templates/node-express/files/src/tests/message_queue/kafka/consumer.js +23 -0
- package/templates/node-express/files/src/tests/message_queue/kafka/producer.js +21 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/consumer.js +28 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.consumer.js +26 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.producer.js +31 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producer.js +26 -0
- package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producerDLX.js +44 -0
- package/templates/node-express/files/src/tests/mysql/connect.js +59 -0
- package/templates/node-express/files/src/tests/product.test.js +14 -0
- package/templates/node-express/files/src/utils/index.js +78 -0
- package/templates/node-express/files/src/utils/reasonPhrases.js +344 -0
- package/templates/node-express/files/src/utils/statusCodes.js +344 -0
- package/templates/node-express/files/src/utils/template.html.js +38 -0
- package/templates/node-express/template.json +11 -0
- package/templates/react-fsd/files/index.html +13 -0
- package/templates/react-fsd/files/package.json +15 -0
- package/templates/react-fsd/files/public/favicon.svg +1 -0
- package/templates/react-fsd/files/public/icons.svg +24 -0
- package/templates/react-fsd/files/src/assets/hero.png +0 -0
- package/templates/react-fsd/files/src/assets/typescript.svg +1 -0
- package/templates/react-fsd/files/src/assets/vite.svg +1 -0
- package/templates/react-fsd/files/src/counter.ts +9 -0
- package/templates/react-fsd/files/src/main.ts +60 -0
- package/templates/react-fsd/files/src/style.css +296 -0
- package/templates/react-fsd/files/tsconfig.json +24 -0
- package/templates/react-fsd/template.json +11 -0
- package/templates/react-monolithic/files/index.html +13 -0
- package/templates/react-monolithic/files/package.json +15 -0
- package/templates/react-monolithic/files/public/favicon.svg +1 -0
- package/templates/react-monolithic/files/public/icons.svg +24 -0
- package/templates/react-monolithic/files/src/assets/hero.png +0 -0
- package/templates/react-monolithic/files/src/assets/typescript.svg +1 -0
- package/templates/react-monolithic/files/src/assets/vite.svg +1 -0
- package/templates/react-monolithic/files/src/counter.ts +9 -0
- package/templates/react-monolithic/files/src/main.ts +60 -0
- package/templates/react-monolithic/files/src/style.css +296 -0
- package/templates/react-monolithic/files/tsconfig.json +24 -0
- package/templates/react-monolithic/template.json +11 -0
- package/templates/vue-fsd/files/index.html +13 -0
- package/templates/vue-fsd/files/package.json +15 -0
- package/templates/vue-fsd/files/public/favicon.svg +1 -0
- package/templates/vue-fsd/files/public/icons.svg +24 -0
- package/templates/vue-fsd/files/src/assets/hero.png +0 -0
- package/templates/vue-fsd/files/src/assets/typescript.svg +1 -0
- package/templates/vue-fsd/files/src/assets/vite.svg +1 -0
- package/templates/vue-fsd/files/src/counter.ts +9 -0
- package/templates/vue-fsd/files/src/main.ts +60 -0
- package/templates/vue-fsd/files/src/style.css +296 -0
- package/templates/vue-fsd/files/tsconfig.json +24 -0
- package/templates/vue-fsd/template.json +11 -0
- package/templates/vue-monolithic/files/index.html +13 -0
- package/templates/vue-monolithic/files/package.json +15 -0
- package/templates/vue-monolithic/files/public/favicon.svg +1 -0
- package/templates/vue-monolithic/files/public/icons.svg +24 -0
- package/templates/vue-monolithic/files/src/assets/hero.png +0 -0
- package/templates/vue-monolithic/files/src/assets/typescript.svg +1 -0
- package/templates/vue-monolithic/files/src/assets/vite.svg +1 -0
- package/templates/vue-monolithic/files/src/counter.ts +9 -0
- package/templates/vue-monolithic/files/src/main.ts +60 -0
- package/templates/vue-monolithic/files/src/style.css +296 -0
- package/templates/vue-monolithic/files/tsconfig.json +24 -0
- package/templates/vue-monolithic/template.json +11 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "prisma-postgres",
|
|
3
|
+
"label": "PostgreSQL + Prisma (adapter-pg)",
|
|
4
|
+
"default": true,
|
|
5
|
+
"templates": ["nest-ddd"],
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@prisma/adapter-pg": "^7.10.0",
|
|
8
|
+
"@prisma/client": "^7.10.0",
|
|
9
|
+
"pg": "^8.23.0"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/pg": "^8.23.1",
|
|
13
|
+
"prisma": "7.10.0"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"postinstall": "prisma generate || exit 0",
|
|
17
|
+
"prisma:generate": "prisma generate",
|
|
18
|
+
"prisma:migrate": "prisma migrate dev",
|
|
19
|
+
"prisma:deploy": "prisma migrate deploy",
|
|
20
|
+
"prisma:studio": "prisma studio"
|
|
21
|
+
},
|
|
22
|
+
"env": [
|
|
23
|
+
"# PostgreSQL (docker-compose.yml)",
|
|
24
|
+
"DATABASE_URL=\"postgresql://root:rootpassword@localhost:5432/{{PROJECT_NAME_SNAKE}}_db?schema=public\""
|
|
25
|
+
],
|
|
26
|
+
"docker": {
|
|
27
|
+
"file": "docker.service.yml",
|
|
28
|
+
"volumes": ["postgres_data"]
|
|
29
|
+
},
|
|
30
|
+
"inject": [
|
|
31
|
+
{
|
|
32
|
+
"file": "src/app.module.ts",
|
|
33
|
+
"marker": "// @infra:imports",
|
|
34
|
+
"lines": ["import { DatabaseModule } from './core/infrastructure/database/database.module.js';"]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"file": "src/app.module.ts",
|
|
38
|
+
"marker": "// @infra:modules",
|
|
39
|
+
"lines": ["DatabaseModule,"]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"file": "src/core/index.ts",
|
|
43
|
+
"marker": "// @infra:barrel",
|
|
44
|
+
"lines": [
|
|
45
|
+
"export * from './infrastructure/database/prisma.service.js';",
|
|
46
|
+
"export * from './infrastructure/database/database.module.js';"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"file": "src/core/config/env.validation.ts",
|
|
51
|
+
"marker": "// @infra:env-imports",
|
|
52
|
+
"lines": ["import { validateDatabaseEnv } from './database.env.js';"]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"file": "src/core/config/env.validation.ts",
|
|
56
|
+
"marker": "// @infra:env-checks",
|
|
57
|
+
"lines": ["validateDatabaseEnv(config, errors);"]
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
rabbitmq:
|
|
2
|
+
image: rabbitmq:3.12-management-alpine
|
|
3
|
+
container_name: {{PROJECT_NAME_SNAKE}}_rabbitmq
|
|
4
|
+
# RabbitMQ keeps its data under a folder named after the node, and the node is named after the
|
|
5
|
+
# host name. Without a fixed one, a re-created container gets a new random name and starts with
|
|
6
|
+
# an EMPTY broker, leaving the queues and messages in the volume unused.
|
|
7
|
+
hostname: {{PROJECT_NAME}}-rabbitmq
|
|
8
|
+
environment:
|
|
9
|
+
RABBITMQ_DEFAULT_USER: admin
|
|
10
|
+
RABBITMQ_DEFAULT_PASS: admin
|
|
11
|
+
ports:
|
|
12
|
+
- '5672:5672'
|
|
13
|
+
- '15672:15672'
|
|
14
|
+
volumes:
|
|
15
|
+
- rabbitmq_data:/var/lib/rabbitmq
|
|
16
|
+
restart: unless-stopped
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const JOB_QUEUES = ['rabbitmq', 'inline'];
|
|
2
|
+
// Where the built-in local RabbitMQ address may be used
|
|
3
|
+
const LOCAL_ENVIRONMENTS = [undefined, 'development', 'test'];
|
|
4
|
+
|
|
5
|
+
/** JOB_QUEUE must be known, `inline` is dev-only, and production must say where RabbitMQ is. */
|
|
6
|
+
export function validateMessagingEnv(
|
|
7
|
+
config: Record<string, unknown>,
|
|
8
|
+
errors: string[],
|
|
9
|
+
): void {
|
|
10
|
+
const jobQueue = (config.JOB_QUEUE ?? 'rabbitmq') as string;
|
|
11
|
+
|
|
12
|
+
if (!JOB_QUEUES.includes(jobQueue)) {
|
|
13
|
+
errors.push(
|
|
14
|
+
`JOB_QUEUE must be one of: ${JOB_QUEUES.join(', ')} (got "${jobQueue}")`,
|
|
15
|
+
);
|
|
16
|
+
} else if (jobQueue === 'inline' && config.NODE_ENV === 'production') {
|
|
17
|
+
errors.push(
|
|
18
|
+
'JOB_QUEUE=inline runs background work inside the request and is not allowed in production; use rabbitmq',
|
|
19
|
+
);
|
|
20
|
+
} else if (
|
|
21
|
+
jobQueue === 'rabbitmq' &&
|
|
22
|
+
!LOCAL_ENVIRONMENTS.includes(config.NODE_ENV as string | undefined) &&
|
|
23
|
+
!(typeof config.RABBITMQ_URL === 'string' && config.RABBITMQ_URL.trim())
|
|
24
|
+
) {
|
|
25
|
+
errors.push(
|
|
26
|
+
'RABBITMQ_URL must be set unless NODE_ENV is development, test or unset, when JOB_QUEUE=rabbitmq',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Logger } from '@nestjs/common';
|
|
3
|
+
import { InlineJobQueue } from './inline-job-queue.js';
|
|
4
|
+
import { defineJob } from './job-queue.port.js';
|
|
5
|
+
|
|
6
|
+
const JOB = defineJob<{ to: string }>({
|
|
7
|
+
name: 'send-email',
|
|
8
|
+
retryDelaysMs: [],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('InlineJobQueue', () => {
|
|
12
|
+
let queue: InlineJobQueue;
|
|
13
|
+
let logged: string[];
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
logged = [];
|
|
17
|
+
for (const level of ['warn', 'error'] as const) {
|
|
18
|
+
vi.spyOn(Logger.prototype, level).mockImplementation((message: any) => {
|
|
19
|
+
logged.push(String(message));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
queue = new InlineJobQueue();
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => vi.restoreAllMocks());
|
|
25
|
+
|
|
26
|
+
it('runs the handler right away, inside the call (AC-9)', async () => {
|
|
27
|
+
const handler = vi.fn().mockResolvedValue(undefined);
|
|
28
|
+
queue.register(JOB, handler);
|
|
29
|
+
|
|
30
|
+
await queue.enqueue(JOB, { to: 'a@example.com' });
|
|
31
|
+
|
|
32
|
+
expect(handler).toHaveBeenCalledWith({ to: 'a@example.com' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('logs a failing handler without the data and does not throw or retry (AC-9)', async () => {
|
|
36
|
+
const handler = vi.fn().mockRejectedValue(
|
|
37
|
+
Object.assign(new Error('550 <a@example.com> unknown'), {
|
|
38
|
+
code: 'EENVELOPE',
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
queue.register(JOB, handler);
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
queue.enqueue(JOB, { to: 'a@example.com' }),
|
|
45
|
+
).resolves.toBeUndefined();
|
|
46
|
+
|
|
47
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
48
|
+
const everything = logged.join('\n');
|
|
49
|
+
expect(everything).toContain('EENVELOPE');
|
|
50
|
+
expect(everything).not.toContain('a@example.com');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('drops a job nobody handles, with a warning', async () => {
|
|
54
|
+
await expect(
|
|
55
|
+
queue.enqueue(JOB, { to: 'a@example.com' }),
|
|
56
|
+
).resolves.toBeUndefined();
|
|
57
|
+
|
|
58
|
+
expect(logged.join('\n')).toContain('No handler registered');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
import type { IJobQueue, JobDefinition, JobHandler } from './job-queue.port.js';
|
|
3
|
+
import { describeFailure } from './job-failure.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runs the job right away, inside the call to `enqueue`. For tests and for development
|
|
7
|
+
* without a broker. There are no retries: a handler that throws is logged and dropped.
|
|
8
|
+
*/
|
|
9
|
+
export class InlineJobQueue implements IJobQueue {
|
|
10
|
+
private readonly logger = new Logger(InlineJobQueue.name);
|
|
11
|
+
private readonly handlers = new Map<string, JobHandler<never>>();
|
|
12
|
+
|
|
13
|
+
register<T>(job: JobDefinition<T>, handler: JobHandler<T>): void {
|
|
14
|
+
this.handlers.set(job.name, handler as JobHandler<never>);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async enqueue<T>(job: JobDefinition<T>, data: T): Promise<void> {
|
|
18
|
+
const handler = this.handlers.get(job.name) as JobHandler<T> | undefined;
|
|
19
|
+
if (!handler) {
|
|
20
|
+
this.logger.warn(`No handler registered for job "${job.name}"; dropped`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await handler(data);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
// Only the kind of failure is logged: the payload and the provider's message can hold
|
|
27
|
+
// personal data (an email address, a one-time code)
|
|
28
|
+
this.logger.error(
|
|
29
|
+
`Job "${job.name}" failed (no retries inline): ${describeFailure(error)}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { describeFailure } from './job-failure.js';
|
|
3
|
+
|
|
4
|
+
describe('describeFailure', () => {
|
|
5
|
+
it('keeps the error code, response code and name, and leaves the message out', () => {
|
|
6
|
+
const error = Object.assign(
|
|
7
|
+
new Error('550 5.1.1 <a@example.com> unknown'),
|
|
8
|
+
{ code: 'EENVELOPE', responseCode: 550 },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
expect(describeFailure(error)).toBe('EENVELOPE 550 Error');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('strips anything that is not a plain word character, so a code cannot smuggle text into the log', () => {
|
|
15
|
+
expect(describeFailure({ code: 'a@b.com\nBcc: x' })).toBe('ab.comBccx');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('copes with values that are not errors', () => {
|
|
19
|
+
expect(describeFailure(undefined)).toBe('unknown error');
|
|
20
|
+
expect(describeFailure('boom')).toBe('unknown error');
|
|
21
|
+
expect(describeFailure(null)).toBe('unknown error');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short, safe description of why a job failed: the error's code and name only. The
|
|
3
|
+
* message is left out on purpose, because SMTP and driver messages often quote the
|
|
4
|
+
* recipient's address or the data that was being processed.
|
|
5
|
+
*/
|
|
6
|
+
export function describeFailure(error: unknown): string {
|
|
7
|
+
const e = error as { code?: unknown; responseCode?: unknown; name?: unknown };
|
|
8
|
+
const parts = [e?.code, e?.responseCode, e?.name]
|
|
9
|
+
.filter(
|
|
10
|
+
(part): part is string | number =>
|
|
11
|
+
typeof part === 'string' || typeof part === 'number',
|
|
12
|
+
)
|
|
13
|
+
.map((part) =>
|
|
14
|
+
String(part)
|
|
15
|
+
.replace(/[^\w.-]/g, '')
|
|
16
|
+
.slice(0, 40),
|
|
17
|
+
);
|
|
18
|
+
return parts.filter(Boolean).join(' ') || 'unknown error';
|
|
19
|
+
}
|
package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.spec.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { JobPayloadCipher } from './job-payload-cipher.js';
|
|
3
|
+
|
|
4
|
+
const MESSAGE = {
|
|
5
|
+
to: 'jane.doe@example.com',
|
|
6
|
+
subject: 'Your code',
|
|
7
|
+
text: 'Code: 482915',
|
|
8
|
+
html: '<p>Code: <b>482915</b></p>',
|
|
9
|
+
};
|
|
10
|
+
const SECRET = 's'.repeat(32);
|
|
11
|
+
|
|
12
|
+
describe('JobPayloadCipher', () => {
|
|
13
|
+
const cipher = new JobPayloadCipher(SECRET, 'test-v1');
|
|
14
|
+
|
|
15
|
+
it('gives back exactly the message that was encrypted', () => {
|
|
16
|
+
expect(cipher.decrypt(cipher.encrypt(MESSAGE))).toEqual(MESSAGE);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('keeps the address, the code and the text out of the encrypted form (AC-5)', () => {
|
|
20
|
+
const payload = cipher.encrypt(MESSAGE);
|
|
21
|
+
|
|
22
|
+
expect(payload).not.toContain('jane.doe');
|
|
23
|
+
expect(payload).not.toContain('482915');
|
|
24
|
+
expect(payload).not.toContain('Your code');
|
|
25
|
+
// base64 must not just be hiding the plain text
|
|
26
|
+
for (const part of payload.split('.')) {
|
|
27
|
+
const decoded = Buffer.from(part, 'base64').toString('latin1');
|
|
28
|
+
expect(decoded).not.toContain('482915');
|
|
29
|
+
expect(decoded).not.toContain('jane.doe');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('refuses a payload that was made with another label: each kind of job has its own key', () => {
|
|
34
|
+
const other = new JobPayloadCipher(SECRET, 'other-v1');
|
|
35
|
+
|
|
36
|
+
expect(() => cipher.decrypt(other.encrypt(MESSAGE))).toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('works for any JSON value, not only emails', () => {
|
|
40
|
+
expect(cipher.decrypt(cipher.encrypt({ email: 'a@example.com' }))).toEqual({
|
|
41
|
+
email: 'a@example.com',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('produces a different result every time for the same message (fresh IV)', () => {
|
|
46
|
+
expect(cipher.encrypt(MESSAGE)).not.toBe(cipher.encrypt(MESSAGE));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('refuses a payload that was made with another secret', () => {
|
|
50
|
+
const other = new JobPayloadCipher('t'.repeat(32), 'test-v1');
|
|
51
|
+
|
|
52
|
+
expect(() => cipher.decrypt(other.encrypt(MESSAGE))).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('refuses a payload that was altered', () => {
|
|
56
|
+
const [iv, tag, ciphertext] = cipher.encrypt(MESSAGE).split('.');
|
|
57
|
+
const bytes = Buffer.from(ciphertext, 'base64');
|
|
58
|
+
bytes[0] ^= 1;
|
|
59
|
+
|
|
60
|
+
expect(() =>
|
|
61
|
+
cipher.decrypt([iv, tag, bytes.toString('base64')].join('.')),
|
|
62
|
+
).toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('refuses a payload whose authentication tag was cut short, which would otherwise still verify', () => {
|
|
66
|
+
const [iv, tag, ciphertext] = cipher.encrypt(MESSAGE).split('.');
|
|
67
|
+
const shortTag = Buffer.from(tag, 'base64').subarray(0, 4);
|
|
68
|
+
|
|
69
|
+
expect(() =>
|
|
70
|
+
cipher.decrypt([iv, shortTag.toString('base64'), ciphertext].join('.')),
|
|
71
|
+
).toThrow(/Malformed/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it.each(['', 'abc', 'a.b', 'a.b.c.d'])(
|
|
75
|
+
'refuses a malformed payload "%s"',
|
|
76
|
+
(payload) => {
|
|
77
|
+
expect(() => cipher.decrypt(payload)).toThrow();
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHmac,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from 'crypto';
|
|
7
|
+
const IV_BYTES = 12;
|
|
8
|
+
const TAG_BYTES = 16;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Encrypts the data of a job while it waits in the queue. Some of it is sensitive (a
|
|
12
|
+
* one-time code, an email address), and anything that sat in the broker in plain text
|
|
13
|
+
* would be readable by anyone who can see the broker. AES-256-GCM, so a payload that was
|
|
14
|
+
* changed in transit does not decrypt either.
|
|
15
|
+
*
|
|
16
|
+
* The key is derived from the server secret under a label of its own, so no new secret has
|
|
17
|
+
* to be configured and each kind of job gets a different key.
|
|
18
|
+
*/
|
|
19
|
+
export class JobPayloadCipher {
|
|
20
|
+
private readonly key: Buffer;
|
|
21
|
+
|
|
22
|
+
constructor(secret: string, label: string) {
|
|
23
|
+
this.key = createHmac('sha256', secret).update(label).digest();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `iv.tag.ciphertext`, each part base64. A fresh random IV every time. */
|
|
27
|
+
encrypt<T>(value: T): string {
|
|
28
|
+
const iv = randomBytes(IV_BYTES);
|
|
29
|
+
const cipher = createCipheriv('aes-256-gcm', this.key, iv, {
|
|
30
|
+
authTagLength: TAG_BYTES,
|
|
31
|
+
});
|
|
32
|
+
const ciphertext = Buffer.concat([
|
|
33
|
+
cipher.update(JSON.stringify(value), 'utf8'),
|
|
34
|
+
cipher.final(),
|
|
35
|
+
]);
|
|
36
|
+
return [iv, cipher.getAuthTag(), ciphertext]
|
|
37
|
+
.map((part) => part.toString('base64'))
|
|
38
|
+
.join('.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Throws when the payload is malformed, was altered, or was made with another key. */
|
|
42
|
+
decrypt<T>(payload: string): T {
|
|
43
|
+
const [iv, tag, ciphertext] = payload
|
|
44
|
+
.split('.')
|
|
45
|
+
.map((part) => Buffer.from(part, 'base64'));
|
|
46
|
+
// A shortened tag would still "verify", with a far weaker guarantee, so only the full length is accepted
|
|
47
|
+
if (
|
|
48
|
+
!iv ||
|
|
49
|
+
!tag ||
|
|
50
|
+
!ciphertext ||
|
|
51
|
+
iv.length !== IV_BYTES ||
|
|
52
|
+
tag.length !== TAG_BYTES
|
|
53
|
+
) {
|
|
54
|
+
throw new Error('Malformed job payload');
|
|
55
|
+
}
|
|
56
|
+
const decipher = createDecipheriv('aes-256-gcm', this.key, iv, {
|
|
57
|
+
authTagLength: TAG_BYTES,
|
|
58
|
+
});
|
|
59
|
+
decipher.setAuthTag(tag);
|
|
60
|
+
const plain = Buffer.concat([
|
|
61
|
+
decipher.update(ciphertext),
|
|
62
|
+
decipher.final(),
|
|
63
|
+
]);
|
|
64
|
+
return JSON.parse(plain.toString('utf8')) as T;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.boundary.spec.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
3
|
+
import { join, relative, sep } from 'path';
|
|
4
|
+
|
|
5
|
+
const SRC = join(process.cwd(), 'src');
|
|
6
|
+
const ALLOWED = [join('core', 'infrastructure', 'messaging')];
|
|
7
|
+
|
|
8
|
+
function sourceFiles(dir: string): string[] {
|
|
9
|
+
return readdirSync(dir).flatMap((name) => {
|
|
10
|
+
const path = join(dir, name);
|
|
11
|
+
if (statSync(path).isDirectory()) return sourceFiles(path);
|
|
12
|
+
return path.endsWith('.ts') ? [path] : [];
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('job queue boundary (AC-11)', () => {
|
|
17
|
+
it('keeps amqplib and RabbitMQService inside core/infrastructure/messaging, so business code only knows the IJobQueue port', () => {
|
|
18
|
+
const offenders = sourceFiles(SRC)
|
|
19
|
+
.filter((file) => {
|
|
20
|
+
const path = relative(SRC, file);
|
|
21
|
+
return !ALLOWED.some((allowed) => path.startsWith(allowed + sep));
|
|
22
|
+
})
|
|
23
|
+
// `core/index.ts` only re-exports the module
|
|
24
|
+
.filter((file) => relative(SRC, file) !== join('core', 'index.ts'))
|
|
25
|
+
.filter((file) =>
|
|
26
|
+
/from ['"]amqplib['"]|RabbitMQService|RabbitMqJobQueue/.test(
|
|
27
|
+
readFileSync(file, 'utf8'),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
.map((file) => relative(SRC, file));
|
|
31
|
+
|
|
32
|
+
expect(offenders).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RabbitMQService } from '../rabbitmq/rabbitmq.service.js';
|
|
2
|
+
import { InlineJobQueue } from './inline-job-queue.js';
|
|
3
|
+
import type { IJobQueue, JobQueueOptions } from './job-queue.port.js';
|
|
4
|
+
import { RabbitMqJobQueue } from './rabbitmq-job-queue.js';
|
|
5
|
+
|
|
6
|
+
/** Picks the adapter for `JOB_QUEUE` (validated at boot by `validateEnv`). */
|
|
7
|
+
export function createJobQueue(
|
|
8
|
+
env: NodeJS.ProcessEnv,
|
|
9
|
+
rabbit: RabbitMQService,
|
|
10
|
+
options: JobQueueOptions,
|
|
11
|
+
): IJobQueue {
|
|
12
|
+
const kind = env.JOB_QUEUE ?? 'rabbitmq';
|
|
13
|
+
switch (kind) {
|
|
14
|
+
case 'inline':
|
|
15
|
+
return new InlineJobQueue();
|
|
16
|
+
case 'rabbitmq':
|
|
17
|
+
return new RabbitMqJobQueue(rabbit, options);
|
|
18
|
+
default:
|
|
19
|
+
throw new Error(`Unknown JOB_QUEUE "${kind}"`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Test } from '@nestjs/testing';
|
|
3
|
+
import { JobQueueModule } from './job-queue.module.js';
|
|
4
|
+
import { createJobQueue } from './job-queue.factory.js';
|
|
5
|
+
import { InlineJobQueue } from './inline-job-queue.js';
|
|
6
|
+
import { RabbitMqJobQueue } from './rabbitmq-job-queue.js';
|
|
7
|
+
import { JOB_QUEUE } from './job-queue.port.js';
|
|
8
|
+
import { RabbitMQService } from '../rabbitmq/rabbitmq.service.js';
|
|
9
|
+
|
|
10
|
+
describe('createJobQueue', () => {
|
|
11
|
+
const rabbit = {} as RabbitMQService;
|
|
12
|
+
|
|
13
|
+
it('defaults to RabbitMQ', () => {
|
|
14
|
+
expect(createJobQueue({}, rabbit, {})).toBeInstanceOf(RabbitMqJobQueue);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each([
|
|
18
|
+
['rabbitmq', RabbitMqJobQueue],
|
|
19
|
+
['inline', InlineJobQueue],
|
|
20
|
+
])('picks the adapter named by JOB_QUEUE=%s', (kind, adapter) => {
|
|
21
|
+
expect(createJobQueue({ JOB_QUEUE: kind }, rabbit, {})).toBeInstanceOf(
|
|
22
|
+
adapter,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('refuses an unknown value instead of silently picking one', () => {
|
|
27
|
+
expect(() => createJobQueue({ JOB_QUEUE: 'kafka' }, rabbit, {})).toThrow(
|
|
28
|
+
/Unknown JOB_QUEUE/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('JobQueueModule', () => {
|
|
34
|
+
const original = process.env.JOB_QUEUE;
|
|
35
|
+
let start: ReturnType<typeof vi.spyOn>;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
start = vi.spyOn(RabbitMQService.prototype, 'start');
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
if (original === undefined) delete process.env.JOB_QUEUE;
|
|
42
|
+
else process.env.JOB_QUEUE = original;
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('builds the inline queue and never opens a RabbitMQ connection (AC-9)', async () => {
|
|
47
|
+
process.env.JOB_QUEUE = 'inline';
|
|
48
|
+
|
|
49
|
+
const moduleRef = await Test.createTestingModule({
|
|
50
|
+
imports: [JobQueueModule],
|
|
51
|
+
}).compile();
|
|
52
|
+
await moduleRef.init();
|
|
53
|
+
|
|
54
|
+
expect(moduleRef.get(JOB_QUEUE)).toBeInstanceOf(InlineJobQueue);
|
|
55
|
+
expect(start).not.toHaveBeenCalled();
|
|
56
|
+
expect(moduleRef.get(RabbitMQService).isConnected()).toBe(false);
|
|
57
|
+
await moduleRef.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('reads JOB_QUEUE when the module is built, not when the file is imported', async () => {
|
|
61
|
+
process.env.JOB_QUEUE = 'inline';
|
|
62
|
+
const first = await Test.createTestingModule({
|
|
63
|
+
imports: [JobQueueModule],
|
|
64
|
+
}).compile();
|
|
65
|
+
expect(first.get(JOB_QUEUE)).toBeInstanceOf(InlineJobQueue);
|
|
66
|
+
|
|
67
|
+
process.env.JOB_QUEUE = 'rabbitmq';
|
|
68
|
+
const second = await Test.createTestingModule({
|
|
69
|
+
imports: [JobQueueModule],
|
|
70
|
+
}).compile();
|
|
71
|
+
expect(second.get(JOB_QUEUE)).toBeInstanceOf(RabbitMqJobQueue);
|
|
72
|
+
// Built but not initialised: nothing connected
|
|
73
|
+
expect(start).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { RabbitMQModule } from '../rabbitmq/rabbitmq.module.js';
|
|
3
|
+
import { RabbitMQService } from '../rabbitmq/rabbitmq.service.js';
|
|
4
|
+
import { createJobQueue } from './job-queue.factory.js';
|
|
5
|
+
import {
|
|
6
|
+
JOB_QUEUE,
|
|
7
|
+
JOB_QUEUE_OPTIONS,
|
|
8
|
+
type JobQueueOptions,
|
|
9
|
+
} from './job-queue.port.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Chooses the job queue by `JOB_QUEUE`: `rabbitmq` (default) or `inline`. The choice is made
|
|
13
|
+
* when the module is built, not when this file is imported, so tests can set it first.
|
|
14
|
+
* Global: any feature can inject `JOB_QUEUE` without importing this module.
|
|
15
|
+
*/
|
|
16
|
+
@Global()
|
|
17
|
+
@Module({
|
|
18
|
+
imports: [RabbitMQModule],
|
|
19
|
+
providers: [
|
|
20
|
+
{ provide: JOB_QUEUE_OPTIONS, useValue: {} satisfies JobQueueOptions },
|
|
21
|
+
{
|
|
22
|
+
provide: JOB_QUEUE,
|
|
23
|
+
inject: [RabbitMQService, JOB_QUEUE_OPTIONS],
|
|
24
|
+
useFactory: (rabbit: RabbitMQService, options: JobQueueOptions) =>
|
|
25
|
+
createJobQueue(process.env, rabbit, options),
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
exports: [JOB_QUEUE],
|
|
29
|
+
})
|
|
30
|
+
export class JobQueueModule {}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Describes one kind of background job. Business code declares these and never
|
|
3
|
+
* touches the message broker: it only knows this port.
|
|
4
|
+
*/
|
|
5
|
+
export interface JobDefinition<T = unknown> {
|
|
6
|
+
/** Unique, stable name (also the routing key). Lowercase words joined by dashes. */
|
|
7
|
+
readonly name: string;
|
|
8
|
+
/**
|
|
9
|
+
* Wait before each retry, in milliseconds. Its length is the number of retries:
|
|
10
|
+
* `[10_000, 30_000]` means up to 3 runs in total, the 2nd after 10 s and the 3rd after
|
|
11
|
+
* 30 s more. A job that still fails after the last retry is set aside (dead-lettered).
|
|
12
|
+
*/
|
|
13
|
+
readonly retryDelaysMs: readonly number[];
|
|
14
|
+
/** How many jobs of this kind run at the same time. Default 1. */
|
|
15
|
+
readonly concurrency?: number;
|
|
16
|
+
/** Type-only marker so `enqueue` and `register` agree on the payload type. Never set. */
|
|
17
|
+
readonly __payload?: T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type JobHandler<T> = (data: T) => Promise<void>;
|
|
21
|
+
|
|
22
|
+
/** Declares a job; the payload type is carried by `T`. Payloads must be plain JSON. */
|
|
23
|
+
export function defineJob<T>(
|
|
24
|
+
definition: Omit<JobDefinition<T>, '__payload'>,
|
|
25
|
+
): JobDefinition<T> {
|
|
26
|
+
return definition;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs work in the background with retries. Delivery is at-least-once: a job can run
|
|
31
|
+
* twice (e.g. the process died after doing the work but before confirming it), and jobs
|
|
32
|
+
* of one kind are not guaranteed to run in order, so handlers must tolerate both.
|
|
33
|
+
*/
|
|
34
|
+
export interface IJobQueue {
|
|
35
|
+
/**
|
|
36
|
+
* Hands a job over and returns once it is safely stored, not once it has run.
|
|
37
|
+
* Rejects with `ServiceUnavailableException` when it could not be stored.
|
|
38
|
+
*/
|
|
39
|
+
enqueue<T>(job: JobDefinition<T>, data: T): Promise<void>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sets the handler that runs jobs of this kind. Call once, when the module starts. A
|
|
43
|
+
* handler that throws gets the job retried per the definition.
|
|
44
|
+
*/
|
|
45
|
+
register<T>(job: JobDefinition<T>, handler: JobHandler<T>): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** DI token for the active IJobQueue. */
|
|
49
|
+
export const JOB_QUEUE = 'IJobQueue';
|
|
50
|
+
|
|
51
|
+
/** Tuning that only tests change; the defaults are the real behaviour. */
|
|
52
|
+
export interface JobQueueOptions {
|
|
53
|
+
/** Prefix for exchange and queue names, so a test run never touches real queues. */
|
|
54
|
+
namespace?: string;
|
|
55
|
+
/** Multiplies every retry delay (0.01 turns 10 s into 100 ms). */
|
|
56
|
+
retryDelayScale?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const JOB_QUEUE_OPTIONS = 'JOB_QUEUE_OPTIONS';
|