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
package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.spec.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { ConflictException } from '@nestjs/common';
|
|
3
|
+
import {
|
|
4
|
+
IdempotencyService,
|
|
5
|
+
computeIdempotencyFingerprint,
|
|
6
|
+
} from './idempotency.service.js';
|
|
7
|
+
import type { RedisService } from '../redis.service.js';
|
|
8
|
+
|
|
9
|
+
describe('IdempotencyService', () => {
|
|
10
|
+
let redisClient: {
|
|
11
|
+
set: ReturnType<typeof vi.fn>;
|
|
12
|
+
get: ReturnType<typeof vi.fn>;
|
|
13
|
+
del: ReturnType<typeof vi.fn>;
|
|
14
|
+
};
|
|
15
|
+
let redisService: Pick<RedisService, 'getClient'>;
|
|
16
|
+
let service: IdempotencyService;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
redisClient = {
|
|
20
|
+
set: vi.fn(),
|
|
21
|
+
get: vi.fn(),
|
|
22
|
+
del: vi.fn().mockResolvedValue(1),
|
|
23
|
+
};
|
|
24
|
+
redisService = { getClient: () => redisClient as any };
|
|
25
|
+
service = new IdempotencyService(redisService as RedisService);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('runs the action directly when no idempotency key is given', async () => {
|
|
29
|
+
const action = vi.fn().mockResolvedValue('result');
|
|
30
|
+
|
|
31
|
+
const result = await service.execute(undefined, 'fp', action);
|
|
32
|
+
|
|
33
|
+
expect(result).toBe('result');
|
|
34
|
+
expect(action).toHaveBeenCalledOnce();
|
|
35
|
+
expect(redisClient.set).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('claims the key, runs the action once, and marks it completed', async () => {
|
|
39
|
+
redisClient.set.mockResolvedValueOnce('OK');
|
|
40
|
+
const action = vi.fn().mockResolvedValue({ id: '1' });
|
|
41
|
+
|
|
42
|
+
const result = await service.execute('key-1', 'fp-1', action);
|
|
43
|
+
|
|
44
|
+
expect(result).toEqual({ id: '1' });
|
|
45
|
+
expect(action).toHaveBeenCalledOnce();
|
|
46
|
+
expect(redisClient.set).toHaveBeenCalledTimes(2);
|
|
47
|
+
const completedCall = redisClient.set.mock.calls[1];
|
|
48
|
+
expect(JSON.parse(completedCall[1])).toMatchObject({
|
|
49
|
+
status: 'completed',
|
|
50
|
+
fingerprint: 'fp-1',
|
|
51
|
+
response: { id: '1' },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('replays the cached response on retry with the same fingerprint after completion', async () => {
|
|
56
|
+
redisClient.set.mockResolvedValueOnce(null); // NX claim fails: key already exists
|
|
57
|
+
redisClient.get.mockResolvedValueOnce(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
status: 'completed',
|
|
60
|
+
fingerprint: 'fp-1',
|
|
61
|
+
response: { id: '1' },
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const action = vi.fn();
|
|
65
|
+
|
|
66
|
+
const result = await service.execute('key-1', 'fp-1', action);
|
|
67
|
+
|
|
68
|
+
expect(result).toEqual({ id: '1' });
|
|
69
|
+
expect(action).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects a retry with the same key while still processing', async () => {
|
|
73
|
+
redisClient.set.mockResolvedValueOnce(null);
|
|
74
|
+
redisClient.get.mockResolvedValueOnce(
|
|
75
|
+
JSON.stringify({ status: 'processing', fingerprint: 'fp-1' }),
|
|
76
|
+
);
|
|
77
|
+
const action = vi.fn();
|
|
78
|
+
|
|
79
|
+
await expect(service.execute('key-1', 'fp-1', action)).rejects.toThrow(
|
|
80
|
+
ConflictException,
|
|
81
|
+
);
|
|
82
|
+
expect(action).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects a retry that reuses the key with a different fingerprint', async () => {
|
|
86
|
+
redisClient.set.mockResolvedValueOnce(null);
|
|
87
|
+
redisClient.get.mockResolvedValueOnce(
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
status: 'completed',
|
|
90
|
+
fingerprint: 'fp-1',
|
|
91
|
+
response: { id: '1' },
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
const action = vi.fn();
|
|
95
|
+
|
|
96
|
+
await expect(service.execute('key-1', 'fp-2', action)).rejects.toThrow(
|
|
97
|
+
ConflictException,
|
|
98
|
+
);
|
|
99
|
+
expect(action).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('deletes the claim and rethrows if the action fails', async () => {
|
|
103
|
+
redisClient.set.mockResolvedValueOnce('OK');
|
|
104
|
+
const action = vi.fn().mockRejectedValue(new Error('boom'));
|
|
105
|
+
|
|
106
|
+
await expect(service.execute('key-1', 'fp-1', action)).rejects.toThrow(
|
|
107
|
+
'boom',
|
|
108
|
+
);
|
|
109
|
+
expect(redisClient.del).toHaveBeenCalledWith('idempotency:key-1');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('runs the action unprotected if claiming the lock fails (Redis unavailable)', async () => {
|
|
113
|
+
redisClient.set.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
114
|
+
const action = vi.fn().mockResolvedValue('ok-anyway');
|
|
115
|
+
|
|
116
|
+
const result = await service.execute('key-1', 'fp-1', action);
|
|
117
|
+
|
|
118
|
+
expect(result).toBe('ok-anyway');
|
|
119
|
+
expect(action).toHaveBeenCalledOnce();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('still returns the response if marking completion fails', async () => {
|
|
123
|
+
redisClient.set
|
|
124
|
+
.mockResolvedValueOnce('OK') // claim succeeds
|
|
125
|
+
.mockRejectedValueOnce(new Error('write failed')); // completion write fails
|
|
126
|
+
const action = vi.fn().mockResolvedValue('done');
|
|
127
|
+
|
|
128
|
+
const result = await service.execute('key-1', 'fp-1', action);
|
|
129
|
+
|
|
130
|
+
expect(result).toBe('done');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('computeIdempotencyFingerprint', () => {
|
|
135
|
+
it('produces the same hash for the same payload', () => {
|
|
136
|
+
expect(computeIdempotencyFingerprint({ a: 1 })).toBe(
|
|
137
|
+
computeIdempotencyFingerprint({ a: 1 }),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('produces a different hash for a different payload', () => {
|
|
142
|
+
expect(computeIdempotencyFingerprint({ a: 1 })).not.toBe(
|
|
143
|
+
computeIdempotencyFingerprint({ a: 2 }),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Injectable, ConflictException, Logger } from '@nestjs/common';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { RedisService } from '../redis.service.js';
|
|
4
|
+
|
|
5
|
+
interface IdempotencyRecord<T> {
|
|
6
|
+
status: 'processing' | 'completed';
|
|
7
|
+
// Hash of the request payload this key was first used with, so a retry
|
|
8
|
+
// that reuses the same key with a *different* payload is rejected
|
|
9
|
+
// instead of silently replaying the wrong cached response.
|
|
10
|
+
fingerprint: string;
|
|
11
|
+
response?: T;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Short: bounds how long a crashed/never-finishing attempt (or a failed
|
|
15
|
+
// "mark completed" write, see below) can block retries under this key.
|
|
16
|
+
const PROCESSING_TTL_SECONDS = 5 * 60;
|
|
17
|
+
// Long: how long a *finished* result stays replayable for a legitimate
|
|
18
|
+
// client retry.
|
|
19
|
+
const COMPLETED_TTL_SECONDS = 24 * 60 * 60;
|
|
20
|
+
|
|
21
|
+
export function computeIdempotencyFingerprint(payload: unknown): string {
|
|
22
|
+
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Injectable()
|
|
26
|
+
export class IdempotencyService {
|
|
27
|
+
private readonly logger = new Logger(IdempotencyService.name);
|
|
28
|
+
|
|
29
|
+
constructor(private readonly redis: RedisService) {}
|
|
30
|
+
|
|
31
|
+
private key(idempotencyKey: string): string {
|
|
32
|
+
return `idempotency:${idempotencyKey}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runs `action` at most once per idempotencyKey within the TTL window.
|
|
37
|
+
* A retry with the same key and the same requestFingerprint while the
|
|
38
|
+
* first attempt is still running is rejected (409); after it completed,
|
|
39
|
+
* it replays the saved response instead of re-running the command. A
|
|
40
|
+
* retry with the same key but a *different* requestFingerprint is
|
|
41
|
+
* rejected instead of silently replaying an unrelated response. No key
|
|
42
|
+
* means no idempotency check.
|
|
43
|
+
*/
|
|
44
|
+
async execute<T>(
|
|
45
|
+
idempotencyKey: string | undefined,
|
|
46
|
+
requestFingerprint: string,
|
|
47
|
+
action: () => Promise<T>,
|
|
48
|
+
): Promise<T> {
|
|
49
|
+
if (!idempotencyKey) {
|
|
50
|
+
return action();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const key = this.key(idempotencyKey);
|
|
54
|
+
const client = this.redis.getClient();
|
|
55
|
+
|
|
56
|
+
let claimed: string | null;
|
|
57
|
+
try {
|
|
58
|
+
// Atomic claim: only the first request for this key gets NX to
|
|
59
|
+
// succeed. Bypasses RedisService's cache helpers (which swallow
|
|
60
|
+
// errors) because a failure here must be handled explicitly below.
|
|
61
|
+
claimed = await client.set(
|
|
62
|
+
key,
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
status: 'processing',
|
|
65
|
+
fingerprint: requestFingerprint,
|
|
66
|
+
} satisfies IdempotencyRecord<T>),
|
|
67
|
+
'EX',
|
|
68
|
+
PROCESSING_TTL_SECONDS,
|
|
69
|
+
'NX',
|
|
70
|
+
);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
// Redis unreachable/timed out (see commandTimeout on RedisService) —
|
|
73
|
+
// treat idempotency as best-effort infrastructure, not a hard
|
|
74
|
+
// dependency: run the action unprotected rather than failing (or
|
|
75
|
+
// hanging) the whole request over a duplicate-prevention nicety.
|
|
76
|
+
this.logger.warn(
|
|
77
|
+
`Idempotency claim failed for key ${idempotencyKey}, proceeding without protection: ${(err as Error).message}`,
|
|
78
|
+
);
|
|
79
|
+
return action();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (claimed === null) {
|
|
83
|
+
const existingRaw = await client.get(key);
|
|
84
|
+
const existing = existingRaw
|
|
85
|
+
? (JSON.parse(existingRaw) as IdempotencyRecord<T>)
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
if (existing && existing.fingerprint !== requestFingerprint) {
|
|
89
|
+
throw new ConflictException(
|
|
90
|
+
'This Idempotency-Key was already used with different request parameters',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (existing?.status === 'completed') {
|
|
94
|
+
return existing.response as T;
|
|
95
|
+
}
|
|
96
|
+
throw new ConflictException(
|
|
97
|
+
'A request with this Idempotency-Key is already being processed',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const response = await action();
|
|
103
|
+
try {
|
|
104
|
+
await client.set(
|
|
105
|
+
key,
|
|
106
|
+
JSON.stringify({
|
|
107
|
+
status: 'completed',
|
|
108
|
+
fingerprint: requestFingerprint,
|
|
109
|
+
response,
|
|
110
|
+
} satisfies IdempotencyRecord<T>),
|
|
111
|
+
'EX',
|
|
112
|
+
COMPLETED_TTL_SECONDS,
|
|
113
|
+
);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// The action already succeeded — don't fail a successful response
|
|
116
|
+
// over bookkeeping. Logged loudly (not swallowed) because it means
|
|
117
|
+
// this key is stuck reporting "processing" until PROCESSING_TTL_
|
|
118
|
+
// SECONDS expires, after which a retry would re-run the action.
|
|
119
|
+
this.logger.error(
|
|
120
|
+
`Failed to record completion for idempotency key ${idempotencyKey}: ${(err as Error).message}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return response;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Don't hold the claim for a failed attempt — a retry should be able
|
|
126
|
+
// to try again rather than being stuck behind a dead "processing".
|
|
127
|
+
await client.del(key).catch(() => undefined);
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
2
|
+
import { LockService } from './lock.service.js';
|
|
3
|
+
import { RedisService } from '../redis.service.js';
|
|
4
|
+
|
|
5
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
|
|
7
|
+
/** Against the real Redis: what matters is how two callers behave when they arrive together. */
|
|
8
|
+
describe('LockService (Integration)', () => {
|
|
9
|
+
let locks: LockService;
|
|
10
|
+
let redis: RedisService;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
14
|
+
providers: [LockService, RedisService],
|
|
15
|
+
}).compile();
|
|
16
|
+
locks = module.get(LockService);
|
|
17
|
+
redis = module.get(RedisService);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
const keys = await redis.getClient().keys('*:lock:*');
|
|
22
|
+
if (keys.length > 0) await redis.getClient().del(...keys);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(async () => {
|
|
26
|
+
await redis.getClient().quit();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('runs the work and returns its result', async () => {
|
|
30
|
+
await expect(locks.run('t-1', async () => 42)).resolves.toBe(42);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('makes two callers for the same name take turns, never overlap', async () => {
|
|
34
|
+
const events: string[] = [];
|
|
35
|
+
const job = (label: string) => async () => {
|
|
36
|
+
events.push(`${label} start`);
|
|
37
|
+
await sleep(150);
|
|
38
|
+
events.push(`${label} end`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
await Promise.all([locks.run('t-2', job('a')), locks.run('t-2', job('b'))]);
|
|
42
|
+
|
|
43
|
+
expect(events).toHaveLength(4);
|
|
44
|
+
// whoever started first also ended before the other started
|
|
45
|
+
expect(events[1]).toMatch(/end$/);
|
|
46
|
+
expect(events[1].split(' ')[0]).toBe(events[0].split(' ')[0]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('lets different names run at the same time', async () => {
|
|
50
|
+
const started: string[] = [];
|
|
51
|
+
const job = (label: string) => async () => {
|
|
52
|
+
started.push(label);
|
|
53
|
+
await sleep(200);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const begin = Date.now();
|
|
57
|
+
await Promise.all([
|
|
58
|
+
locks.run('t-3a', job('a')),
|
|
59
|
+
locks.run('t-3b', job('b')),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
expect(Date.now() - begin).toBeLessThan(380);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('gives up with a 409 when the other one takes too long', async () => {
|
|
66
|
+
const holder = locks.run('t-4', () => sleep(400));
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
locks.run('t-4', async () => 'never', { waitMs: 100 }),
|
|
70
|
+
).rejects.toMatchObject({ code: 'SYS-002', status: 409 });
|
|
71
|
+
|
|
72
|
+
await holder;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('lets go when the work fails, so the next caller is not stuck', async () => {
|
|
76
|
+
await expect(
|
|
77
|
+
locks.run('t-5', async () => {
|
|
78
|
+
throw new Error('boom');
|
|
79
|
+
}),
|
|
80
|
+
).rejects.toThrow('boom');
|
|
81
|
+
|
|
82
|
+
await expect(
|
|
83
|
+
locks.run('t-5', async () => 'ok', { waitMs: 100 }),
|
|
84
|
+
).resolves.toBe('ok');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('frees itself when the holder disappears (the time limit)', async () => {
|
|
88
|
+
await redis
|
|
89
|
+
.getClient()
|
|
90
|
+
.set('{{PROJECT_NAME_SNAKE}}:v1:lock:t-6', 'crashed-holder', 'PX', 100);
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
locks.run('t-6', async () => 'ok', { waitMs: 1000 }),
|
|
94
|
+
).resolves.toBe('ok');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not release a lock that expired and now belongs to somebody else', async () => {
|
|
98
|
+
await locks.run(
|
|
99
|
+
't-7',
|
|
100
|
+
async () => {
|
|
101
|
+
await sleep(150); // longer than the time limit below: the lock expires meanwhile
|
|
102
|
+
await redis
|
|
103
|
+
.getClient()
|
|
104
|
+
.set('{{PROJECT_NAME_SNAKE}}:v1:lock:t-7', 'new-owner', 'PX', 5000);
|
|
105
|
+
},
|
|
106
|
+
{ ttlMs: 50 },
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(await redis.getClient().get('{{PROJECT_NAME_SNAKE}}:v1:lock:t-7')).toBe('new-owner');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
Logger,
|
|
4
|
+
ServiceUnavailableException,
|
|
5
|
+
} from '@nestjs/common';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import { RedisService } from '../redis.service.js';
|
|
8
|
+
import { DomainException } from '../../../domain/exceptions/domain.exception.js';
|
|
9
|
+
import { CoreErrorCodes } from '../../../domain/exceptions/error-codes.js';
|
|
10
|
+
|
|
11
|
+
const KEY_PREFIX = '{{PROJECT_NAME_SNAKE}}:v1:lock:';
|
|
12
|
+
const POLL_INTERVAL_MS = 50;
|
|
13
|
+
// Only the holder may release: a lock that expired and was taken by somebody else stays theirs
|
|
14
|
+
const RELEASE_SCRIPT = `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end`;
|
|
15
|
+
|
|
16
|
+
export interface LockOptions {
|
|
17
|
+
/** How long the lock lives if the holder never lets go (crash). Must exceed the work. */
|
|
18
|
+
ttlMs?: number;
|
|
19
|
+
/** How long to wait for somebody else to finish before giving up with a 409. */
|
|
20
|
+
waitMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lets one request at a time work on one thing across all server instances, e.g. the avatar
|
|
25
|
+
* of a user: an upload and a removal arriving together then run one after the other instead
|
|
26
|
+
* of overlapping.
|
|
27
|
+
*/
|
|
28
|
+
@Injectable()
|
|
29
|
+
export class LockService {
|
|
30
|
+
private readonly logger = new Logger(LockService.name);
|
|
31
|
+
|
|
32
|
+
constructor(private readonly redis: RedisService) {}
|
|
33
|
+
|
|
34
|
+
async run<T>(
|
|
35
|
+
name: string,
|
|
36
|
+
work: () => Promise<T>,
|
|
37
|
+
{ ttlMs = 60_000, waitMs = 10_000 }: LockOptions = {},
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
const key = KEY_PREFIX + name;
|
|
40
|
+
const owner = randomUUID();
|
|
41
|
+
await this.acquire(key, owner, ttlMs, waitMs);
|
|
42
|
+
try {
|
|
43
|
+
return await work();
|
|
44
|
+
} finally {
|
|
45
|
+
await this.release(key, owner);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async acquire(
|
|
50
|
+
key: string,
|
|
51
|
+
owner: string,
|
|
52
|
+
ttlMs: number,
|
|
53
|
+
waitMs: number,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const giveUpAt = Date.now() + waitMs;
|
|
56
|
+
for (;;) {
|
|
57
|
+
let taken: string | null;
|
|
58
|
+
try {
|
|
59
|
+
taken = await this.redis.getClient().set(key, owner, 'PX', ttlMs, 'NX');
|
|
60
|
+
} catch (error: any) {
|
|
61
|
+
this.logger.error(`Redis lock failed: ${error.message}`);
|
|
62
|
+
throw new ServiceUnavailableException(
|
|
63
|
+
'Service temporarily unavailable',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (taken === 'OK') return;
|
|
67
|
+
if (Date.now() >= giveUpAt) {
|
|
68
|
+
throw new DomainException(CoreErrorCodes.RESOURCE_BUSY);
|
|
69
|
+
}
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async release(key: string, owner: string): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
await this.redis.getClient().eval(RELEASE_SCRIPT, 1, key, owner);
|
|
77
|
+
} catch (error: any) {
|
|
78
|
+
// The work is done; the lock expires by itself, so this must not turn it into a failure
|
|
79
|
+
this.logger.warn(`Releasing a lock failed: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { APP_INTERCEPTOR } from '@nestjs/core';
|
|
3
|
+
import { RateLimitService } from './rate-limit.service.js';
|
|
4
|
+
import { RateLimitInterceptor } from '../../../http/interceptors/rate-limit.interceptor.js';
|
|
5
|
+
|
|
6
|
+
// The interceptor is global but inert: it only acts on routes carrying @RateLimit().
|
|
7
|
+
@Global()
|
|
8
|
+
@Module({
|
|
9
|
+
providers: [
|
|
10
|
+
RateLimitService,
|
|
11
|
+
{ provide: APP_INTERCEPTOR, useClass: RateLimitInterceptor },
|
|
12
|
+
],
|
|
13
|
+
exports: [RateLimitService],
|
|
14
|
+
})
|
|
15
|
+
export class RateLimitModule {}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { RateLimitService } from './rate-limit.service.js';
|
|
3
|
+
import type { RedisService } from '../redis.service.js';
|
|
4
|
+
|
|
5
|
+
const serviceReturning = (count: number | Error) => {
|
|
6
|
+
const evalFn = vi.fn();
|
|
7
|
+
if (count instanceof Error) evalFn.mockRejectedValue(count);
|
|
8
|
+
else evalFn.mockResolvedValue(count);
|
|
9
|
+
const service = new RateLimitService({
|
|
10
|
+
getClient: () => ({ eval: evalFn }),
|
|
11
|
+
} as unknown as RedisService);
|
|
12
|
+
return { service, evalFn };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe('RateLimitService.isAllowed', () => {
|
|
16
|
+
// limit = 5 means "5 requests allowed, the 6th is blocked" (spec SC-5).
|
|
17
|
+
// `count <= limit` — NOT `count >= limit`, which would block the 5th request.
|
|
18
|
+
it.each([1, 2, 3, 4, 5])('allows hit number %i of 5', async (count) => {
|
|
19
|
+
const { service } = serviceReturning(count);
|
|
20
|
+
await expect(service.isAllowed('k', 5, 900)).resolves.toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([6, 7, 100])('blocks hit number %i of 5', async (count) => {
|
|
24
|
+
const { service } = serviceReturning(count);
|
|
25
|
+
await expect(service.isAllowed('k', 5, 900)).resolves.toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('sends the bucket key and window to the atomic script', async () => {
|
|
29
|
+
const { service, evalFn } = serviceReturning(1);
|
|
30
|
+
|
|
31
|
+
await service.isAllowed('bucket-key', 3, 3600);
|
|
32
|
+
|
|
33
|
+
const [script, numKeys, key, window] = evalFn.mock.calls[0];
|
|
34
|
+
expect(script).toContain('INCR');
|
|
35
|
+
expect(script).toContain('EXPIRE');
|
|
36
|
+
expect([numKeys, key, window]).toEqual([1, 'bucket-key', 3600]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects when Redis fails, so the caller decides whether to fail open', async () => {
|
|
40
|
+
const { service } = serviceReturning(new Error('ECONNREFUSED'));
|
|
41
|
+
await expect(service.isAllowed('k', 5, 900)).rejects.toThrow(
|
|
42
|
+
'ECONNREFUSED',
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { RedisService } from '../redis.service.js';
|
|
3
|
+
|
|
4
|
+
// INCR + EXPIRE in one atomic step: a crash between two separate commands
|
|
5
|
+
// would leave a counter with no TTL, locking the caller out forever.
|
|
6
|
+
const INCREMENT_IN_WINDOW_SCRIPT = `
|
|
7
|
+
local current = redis.call("INCR", KEYS[1])
|
|
8
|
+
if current == 1 then
|
|
9
|
+
redis.call("EXPIRE", KEYS[1], ARGV[1])
|
|
10
|
+
end
|
|
11
|
+
return current
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fixed-window counter on Redis. The window starts at the first hit of a key
|
|
16
|
+
* and the counter expires `window` seconds later.
|
|
17
|
+
*/
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class RateLimitService {
|
|
20
|
+
constructor(private readonly redis: RedisService) {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param key bucket key, see CACHE.RATE_LIMIT.KEYS.BUCKET
|
|
24
|
+
* @param limit max hits allowed per window
|
|
25
|
+
* @param window window length in seconds
|
|
26
|
+
* @throws if Redis is unreachable — the caller decides whether to fail open
|
|
27
|
+
*/
|
|
28
|
+
async isAllowed(
|
|
29
|
+
key: string,
|
|
30
|
+
limit: number,
|
|
31
|
+
window: number,
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
const count = (await this.redis
|
|
34
|
+
.getClient()
|
|
35
|
+
.eval(INCREMENT_IN_WINDOW_SCRIPT, 1, key, window)) as number;
|
|
36
|
+
return count <= limit;
|
|
37
|
+
}
|
|
38
|
+
}
|