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.
Files changed (327) hide show
  1. package/bin/create.mjs +244 -0
  2. package/infra/email/files/src/core/config/email.env.ts +73 -0
  3. package/infra/email/files/src/core/infrastructure/email/email-dispatcher.port.ts +13 -0
  4. package/infra/email/files/src/core/infrastructure/email/email-layout.spec.ts +83 -0
  5. package/infra/email/files/src/core/infrastructure/email/email-layout.ts +88 -0
  6. package/infra/email/files/src/core/infrastructure/email/email-sender.factory.spec.ts +87 -0
  7. package/infra/email/files/src/core/infrastructure/email/email-sender.factory.ts +35 -0
  8. package/infra/email/files/src/core/infrastructure/email/email-sender.port.ts +19 -0
  9. package/infra/email/files/src/core/infrastructure/email/email.job.spec.ts +101 -0
  10. package/infra/email/files/src/core/infrastructure/email/email.job.ts +64 -0
  11. package/infra/email/files/src/core/infrastructure/email/email.module.spec.ts +60 -0
  12. package/infra/email/files/src/core/infrastructure/email/email.module.ts +34 -0
  13. package/infra/email/files/src/core/infrastructure/email/log-email-sender.spec.ts +34 -0
  14. package/infra/email/files/src/core/infrastructure/email/log-email-sender.ts +17 -0
  15. package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.spec.ts +99 -0
  16. package/infra/email/files/src/core/infrastructure/email/smtp-email-sender.ts +63 -0
  17. package/infra/email/infra.json +58 -0
  18. package/infra/prisma-postgres/docker.service.yml +12 -0
  19. package/infra/prisma-postgres/files/prisma/schema.prisma +10 -0
  20. package/infra/prisma-postgres/files/prisma.config.mjs +10 -0
  21. package/infra/prisma-postgres/files/src/core/config/database.env.ts +13 -0
  22. package/infra/prisma-postgres/files/src/core/infrastructure/database/database.module.ts +9 -0
  23. package/infra/prisma-postgres/files/src/core/infrastructure/database/prisma.service.ts +26 -0
  24. package/infra/prisma-postgres/infra.json +60 -0
  25. package/infra/rabbitmq/docker.service.yml +16 -0
  26. package/infra/rabbitmq/files/src/core/config/messaging.env.ts +29 -0
  27. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.spec.ts +60 -0
  28. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/inline-job-queue.ts +33 -0
  29. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.spec.ts +23 -0
  30. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-failure.ts +19 -0
  31. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.spec.ts +80 -0
  32. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-payload-cipher.ts +66 -0
  33. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.boundary.spec.ts +34 -0
  34. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.factory.ts +21 -0
  35. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.spec.ts +75 -0
  36. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.module.ts +30 -0
  37. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/job-queue.port.ts +59 -0
  38. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.spec.ts +510 -0
  39. package/infra/rabbitmq/files/src/core/infrastructure/messaging/jobs/rabbitmq-job-queue.ts +353 -0
  40. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.module.ts +9 -0
  41. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.spec.ts +289 -0
  42. package/infra/rabbitmq/files/src/core/infrastructure/messaging/rabbitmq/rabbitmq.service.ts +207 -0
  43. package/infra/rabbitmq/infra.json +55 -0
  44. package/infra/redis/docker.service.yml +10 -0
  45. package/infra/redis/files/src/core/http/decorators/rate-limit.decorator.ts +26 -0
  46. package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.spec.ts +180 -0
  47. package/infra/redis/files/src/core/http/interceptors/rate-limit.interceptor.ts +105 -0
  48. package/infra/redis/files/src/core/infrastructure/redis/cache.constants.ts +67 -0
  49. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.module.ts +9 -0
  50. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.spec.ts +146 -0
  51. package/infra/redis/files/src/core/infrastructure/redis/idempotency/idempotency.service.ts +131 -0
  52. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.module.ts +9 -0
  53. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.integration-spec.ts +111 -0
  54. package/infra/redis/files/src/core/infrastructure/redis/lock/lock.service.ts +82 -0
  55. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.module.ts +15 -0
  56. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.spec.ts +45 -0
  57. package/infra/redis/files/src/core/infrastructure/redis/rate-limit/rate-limit.service.ts +38 -0
  58. package/infra/redis/files/src/core/infrastructure/redis/redis.module.ts +9 -0
  59. package/infra/redis/files/src/core/infrastructure/redis/redis.service.ts +196 -0
  60. package/infra/redis/infra.json +53 -0
  61. package/infra/storage-cloudinary/files/src/core/config/storage.env.ts +43 -0
  62. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.spec.ts +275 -0
  63. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/cloudinary-image-storage.ts +158 -0
  64. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.factory.ts +30 -0
  65. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.module.ts +17 -0
  66. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-storage.port.ts +38 -0
  67. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.spec.ts +48 -0
  68. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/image-type.ts +25 -0
  69. package/infra/storage-cloudinary/files/src/core/infrastructure/storage/unconfigured-image-storage.ts +19 -0
  70. package/infra/storage-cloudinary/infra.json +73 -0
  71. package/lib/fs-utils.mjs +115 -0
  72. package/lib/registry.mjs +50 -0
  73. package/lib/scaffold.mjs +157 -0
  74. package/package.json +37 -0
  75. package/templates/nest-ddd/files/.env.example +5 -0
  76. package/templates/nest-ddd/files/.husky/commit-msg +1 -0
  77. package/templates/nest-ddd/files/.husky/pre-commit +1 -0
  78. package/templates/nest-ddd/files/.oxlintrc.json +10 -0
  79. package/templates/nest-ddd/files/.prettierrc +4 -0
  80. package/templates/nest-ddd/files/_gitignore +40 -0
  81. package/templates/nest-ddd/files/commitlint.config.js +3 -0
  82. package/templates/nest-ddd/files/nest-cli.json +8 -0
  83. package/templates/nest-ddd/files/package.json +66 -0
  84. package/templates/nest-ddd/files/plop-templates/command/command.ts.hbs +5 -0
  85. package/templates/nest-ddd/files/plop-templates/command/handler.ts.hbs +30 -0
  86. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.command.ts.hbs +5 -0
  87. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.spec.ts.hbs +35 -0
  88. package/templates/nest-ddd/files/plop-templates/module/application/commands/create-{{kebab}}.handler.ts.hbs +25 -0
  89. package/templates/nest-ddd/files/plop-templates/module/application/index.ts.hbs +5 -0
  90. package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.handler.ts.hbs +29 -0
  91. package/templates/nest-ddd/files/plop-templates/module/application/queries/get-{{kebab}}-by-id.query.ts.hbs +5 -0
  92. package/templates/nest-ddd/files/plop-templates/module/domain/entities/{{kebab}}.entity.ts.hbs +37 -0
  93. package/templates/nest-ddd/files/plop-templates/module/domain/events/{{kebab}}-created.event.ts.hbs +10 -0
  94. package/templates/nest-ddd/files/plop-templates/module/domain/exceptions/{{kebab}}.exception.ts.hbs +11 -0
  95. package/templates/nest-ddd/files/plop-templates/module/domain/index.ts.hbs +4 -0
  96. package/templates/nest-ddd/files/plop-templates/module/domain/repositories/{{kebab}}.repository.ts.hbs +7 -0
  97. package/templates/nest-ddd/files/plop-templates/module/presentation/controllers/{{kebab}}.controller.ts.hbs +53 -0
  98. package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/create-{{kebab}}.dto.ts.hbs +10 -0
  99. package/templates/nest-ddd/files/plop-templates/module/presentation/dtos/{{kebab}}-response.dto.ts.hbs +15 -0
  100. package/templates/nest-ddd/files/plop-templates/module/presentation/index.ts.hbs +4 -0
  101. package/templates/nest-ddd/files/plop-templates/module/presentation/mappers/{{kebab}}.mapper.ts.hbs +13 -0
  102. package/templates/nest-ddd/files/plop-templates/module/{{kebab}}.module.ts.hbs +40 -0
  103. package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/database/repositories/in-memory-{{kebab}}.repository.ts.hbs +17 -0
  104. package/templates/nest-ddd/files/plop-templates/persistence-memory/infrastructure/index.ts.hbs +1 -0
  105. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/mappers/{{kebab}}.mapper.ts.hbs +29 -0
  106. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/database/repositories/prisma-{{kebab}}.repository.ts.hbs +28 -0
  107. package/templates/nest-ddd/files/plop-templates/persistence-prisma/infrastructure/index.ts.hbs +2 -0
  108. package/templates/nest-ddd/files/plop-templates/query/handler.ts.hbs +28 -0
  109. package/templates/nest-ddd/files/plop-templates/query/query.ts.hbs +5 -0
  110. package/templates/nest-ddd/files/plop-templates/snippets/error-codes.hbs +14 -0
  111. package/templates/nest-ddd/files/plop-templates/snippets/prisma-model.hbs +7 -0
  112. package/templates/nest-ddd/files/plopfile.mjs +186 -0
  113. package/templates/nest-ddd/files/src/app.controller.ts +13 -0
  114. package/templates/nest-ddd/files/src/app.module.ts +26 -0
  115. package/templates/nest-ddd/files/src/core/config/app.config.ts +9 -0
  116. package/templates/nest-ddd/files/src/core/config/env.validation.ts +30 -0
  117. package/templates/nest-ddd/files/src/core/domain/base/aggregate-root.base.ts +18 -0
  118. package/templates/nest-ddd/files/src/core/domain/base/domain-event.base.ts +7 -0
  119. package/templates/nest-ddd/files/src/core/domain/base/entity.base.ts +36 -0
  120. package/templates/nest-ddd/files/src/core/domain/base/value-object.base.ts +17 -0
  121. package/templates/nest-ddd/files/src/core/domain/exceptions/domain.exception.ts +16 -0
  122. package/templates/nest-ddd/files/src/core/domain/exceptions/error-codes.ts +28 -0
  123. package/templates/nest-ddd/files/src/core/domain/types/pagination.ts +9 -0
  124. package/templates/nest-ddd/files/src/core/http/decorators/client-info.decorator.ts +24 -0
  125. package/templates/nest-ddd/files/src/core/http/decorators/current-user.decorator.ts +19 -0
  126. package/templates/nest-ddd/files/src/core/http/dtos/paginated-response.dto.ts +29 -0
  127. package/templates/nest-ddd/files/src/core/http/dtos/pagination-query.dto.ts +17 -0
  128. package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.spec.ts +163 -0
  129. package/templates/nest-ddd/files/src/core/http/filters/all-exceptions.filter.ts +97 -0
  130. package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.spec.ts +47 -0
  131. package/templates/nest-ddd/files/src/core/http/guards/max-body-size.guard.ts +31 -0
  132. package/templates/nest-ddd/files/src/core/http/interceptors/logging.interceptor.ts +37 -0
  133. package/templates/nest-ddd/files/src/core/http/interceptors/response.interceptor.ts +53 -0
  134. package/templates/nest-ddd/files/src/core/http/logging/app.logger.ts +34 -0
  135. package/templates/nest-ddd/files/src/core/index.ts +33 -0
  136. package/templates/nest-ddd/files/src/core/utils/correlation-id.util.spec.ts +29 -0
  137. package/templates/nest-ddd/files/src/core/utils/correlation-id.util.ts +15 -0
  138. package/templates/nest-ddd/files/src/core/utils/hash.util.spec.ts +61 -0
  139. package/templates/nest-ddd/files/src/core/utils/hash.util.ts +47 -0
  140. package/templates/nest-ddd/files/src/core/utils/snowflake.util.spec.ts +40 -0
  141. package/templates/nest-ddd/files/src/core/utils/snowflake.util.ts +99 -0
  142. package/templates/nest-ddd/files/src/core/utils/string.util.spec.ts +20 -0
  143. package/templates/nest-ddd/files/src/core/utils/string.util.ts +21 -0
  144. package/templates/nest-ddd/files/src/core/utils/time.util.spec.ts +25 -0
  145. package/templates/nest-ddd/files/src/core/utils/time.util.ts +25 -0
  146. package/templates/nest-ddd/files/src/core/utils/ttl.util.ts +22 -0
  147. package/templates/nest-ddd/files/src/main.ts +61 -0
  148. package/templates/nest-ddd/files/test/setup.ts +6 -0
  149. package/templates/nest-ddd/files/tsconfig.build.json +9 -0
  150. package/templates/nest-ddd/files/tsconfig.json +28 -0
  151. package/templates/nest-ddd/files/vitest.config.e2e.ts +15 -0
  152. package/templates/nest-ddd/files/vitest.config.ts +16 -0
  153. package/templates/nest-ddd/template.json +8 -0
  154. package/templates/node-express/files/.env.example +28 -0
  155. package/templates/node-express/files/docker-compose.yml +52 -0
  156. package/templates/node-express/files/package-lock.json +11880 -0
  157. package/templates/node-express/files/package.json +43 -0
  158. package/templates/node-express/files/server.js +13 -0
  159. package/templates/node-express/files/src/app.js +72 -0
  160. package/templates/node-express/files/src/auth/authUtils.js +116 -0
  161. package/templates/node-express/files/src/auth/checkAuth.js +61 -0
  162. package/templates/node-express/files/src/bucket_pattern/comment.model.js +27 -0
  163. package/templates/node-express/files/src/bucket_pattern/comment.service.js +50 -0
  164. package/templates/node-express/files/src/config/config.aws-s3.js +28 -0
  165. package/templates/node-express/files/src/config/config.cloudinary.js +13 -0
  166. package/templates/node-express/files/src/config/config.mongodb.js +25 -0
  167. package/templates/node-express/files/src/config/config.multer.js +25 -0
  168. package/templates/node-express/files/src/config/config.nodemailer.js +16 -0
  169. package/templates/node-express/files/src/controllers/access.controller.js +49 -0
  170. package/templates/node-express/files/src/controllers/cart.controller.js +38 -0
  171. package/templates/node-express/files/src/controllers/checkout.controller.js +15 -0
  172. package/templates/node-express/files/src/controllers/comment.controller.js +29 -0
  173. package/templates/node-express/files/src/controllers/discount.controller.js +68 -0
  174. package/templates/node-express/files/src/controllers/email.controller.js +16 -0
  175. package/templates/node-express/files/src/controllers/inventory.controller.js +15 -0
  176. package/templates/node-express/files/src/controllers/notification.controller.js +15 -0
  177. package/templates/node-express/files/src/controllers/product.controller.js +175 -0
  178. package/templates/node-express/files/src/controllers/profile.controller.js +65 -0
  179. package/templates/node-express/files/src/controllers/rbac.controller.js +41 -0
  180. package/templates/node-express/files/src/controllers/template.controller.js +20 -0
  181. package/templates/node-express/files/src/controllers/upload.controller.js +56 -0
  182. package/templates/node-express/files/src/controllers/user.controller.js +36 -0
  183. package/templates/node-express/files/src/core/error.response.js +84 -0
  184. package/templates/node-express/files/src/core/success.response.js +44 -0
  185. package/templates/node-express/files/src/database/init.mongodb.level0.js +16 -0
  186. package/templates/node-express/files/src/database/init.mongodb.level1.js +36 -0
  187. package/templates/node-express/files/src/database/init.redis.js +119 -0
  188. package/templates/node-express/files/src/helpers/asyncHandler.js +7 -0
  189. package/templates/node-express/files/src/helpers/check.connect.js +29 -0
  190. package/templates/node-express/files/src/keys/private_key.pem +28 -0
  191. package/templates/node-express/files/src/keys/public_key.pem +9 -0
  192. package/templates/node-express/files/src/logger/discord.log.js +26 -0
  193. package/templates/node-express/files/src/logger/discord.log.v2.js +56 -0
  194. package/templates/node-express/files/src/logger/mylogger.log.js +130 -0
  195. package/templates/node-express/files/src/logger/winston.log.js +27 -0
  196. package/templates/node-express/files/src/middlewares/index.js +21 -0
  197. package/templates/node-express/files/src/middlewares/rbac.js +37 -0
  198. package/templates/node-express/files/src/middlewares/role.middleware.js +12 -0
  199. package/templates/node-express/files/src/models/KeyToken.model.js +37 -0
  200. package/templates/node-express/files/src/models/apiKey.model.js +31 -0
  201. package/templates/node-express/files/src/models/cart.model.js +47 -0
  202. package/templates/node-express/files/src/models/comment.model.js +43 -0
  203. package/templates/node-express/files/src/models/discount.model.js +92 -0
  204. package/templates/node-express/files/src/models/inventory.model.js +42 -0
  205. package/templates/node-express/files/src/models/notification.model.js +52 -0
  206. package/templates/node-express/files/src/models/order.model.js +68 -0
  207. package/templates/node-express/files/src/models/otp.model.js +32 -0
  208. package/templates/node-express/files/src/models/product.model.js +144 -0
  209. package/templates/node-express/files/src/models/repository/cart.repo.js +71 -0
  210. package/templates/node-express/files/src/models/repository/discount.repo.js +63 -0
  211. package/templates/node-express/files/src/models/repository/inventory.repo.js +45 -0
  212. package/templates/node-express/files/src/models/repository/product.repo.js +145 -0
  213. package/templates/node-express/files/src/models/repository/shop.repo.js +18 -0
  214. package/templates/node-express/files/src/models/repository/spu.repo.js +18 -0
  215. package/templates/node-express/files/src/models/repository/user.repo.js +20 -0
  216. package/templates/node-express/files/src/models/resource.model.js +32 -0
  217. package/templates/node-express/files/src/models/role.model.js +57 -0
  218. package/templates/node-express/files/src/models/shop.model.js +43 -0
  219. package/templates/node-express/files/src/models/sku.model.js +81 -0
  220. package/templates/node-express/files/src/models/spu.model.js +101 -0
  221. package/templates/node-express/files/src/models/template.model.js +25 -0
  222. package/templates/node-express/files/src/models/user.model.js +82 -0
  223. package/templates/node-express/files/src/postman/access.post.http +22 -0
  224. package/templates/node-express/files/src/routes/access/index.route.js +21 -0
  225. package/templates/node-express/files/src/routes/cart/index.route.js +20 -0
  226. package/templates/node-express/files/src/routes/checkout/index.route.js +10 -0
  227. package/templates/node-express/files/src/routes/comment/index.route.js +16 -0
  228. package/templates/node-express/files/src/routes/discount/index.route.js +26 -0
  229. package/templates/node-express/files/src/routes/index.route.js +27 -0
  230. package/templates/node-express/files/src/routes/inventory/index.route.js +11 -0
  231. package/templates/node-express/files/src/routes/notification/index.route.js +14 -0
  232. package/templates/node-express/files/src/routes/product/index.route.js +38 -0
  233. package/templates/node-express/files/src/routes/profile/index.route.js +15 -0
  234. package/templates/node-express/files/src/routes/rbac/index.route.js +21 -0
  235. package/templates/node-express/files/src/routes/template/index.route.js +11 -0
  236. package/templates/node-express/files/src/routes/upload/index.route.js +28 -0
  237. package/templates/node-express/files/src/routes/user/index.route.js +13 -0
  238. package/templates/node-express/files/src/services/access.service.js +257 -0
  239. package/templates/node-express/files/src/services/apiKey.service.js +15 -0
  240. package/templates/node-express/files/src/services/cart.service.js +115 -0
  241. package/templates/node-express/files/src/services/checkout.service.js +218 -0
  242. package/templates/node-express/files/src/services/comment.service.js +185 -0
  243. package/templates/node-express/files/src/services/discount.service.js +282 -0
  244. package/templates/node-express/files/src/services/email.service.js +71 -0
  245. package/templates/node-express/files/src/services/inventory.service.js +35 -0
  246. package/templates/node-express/files/src/services/keyToken.service.js +77 -0
  247. package/templates/node-express/files/src/services/notification.service.js +58 -0
  248. package/templates/node-express/files/src/services/otp.service.js +36 -0
  249. package/templates/node-express/files/src/services/product.service.js +228 -0
  250. package/templates/node-express/files/src/services/rbac.service.js +143 -0
  251. package/templates/node-express/files/src/services/redis.service.js +57 -0
  252. package/templates/node-express/files/src/services/redisPubSub.service.js +72 -0
  253. package/templates/node-express/files/src/services/shop.service.js +21 -0
  254. package/templates/node-express/files/src/services/sku.service.js +52 -0
  255. package/templates/node-express/files/src/services/spu.service.js +89 -0
  256. package/templates/node-express/files/src/services/template.service.js +41 -0
  257. package/templates/node-express/files/src/services/upload.s3.service.js +55 -0
  258. package/templates/node-express/files/src/services/upload.service.js +74 -0
  259. package/templates/node-express/files/src/services/user.service.js +92 -0
  260. package/templates/node-express/files/src/temp/1734420604996-ci-cd.png +0 -0
  261. package/templates/node-express/files/src/temp/1734421222213-cover-fb.jpg +0 -0
  262. package/templates/node-express/files/src/temp/1734421222214-cover-fb-01.jpg +0 -0
  263. package/templates/node-express/files/src/temp/1734421293437-cover-fb-01.jpg +0 -0
  264. package/templates/node-express/files/src/temp/1734421293437-cover-fb.jpg +0 -0
  265. package/templates/node-express/files/src/tests/inventory.test.js +21 -0
  266. package/templates/node-express/files/src/tests/message_queue/kafka/consumer.js +23 -0
  267. package/templates/node-express/files/src/tests/message_queue/kafka/producer.js +21 -0
  268. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/consumer.js +28 -0
  269. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.consumer.js +26 -0
  270. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/ordered.producer.js +31 -0
  271. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producer.js +26 -0
  272. package/templates/node-express/files/src/tests/message_queue/rabbitMQ/producerDLX.js +44 -0
  273. package/templates/node-express/files/src/tests/mysql/connect.js +59 -0
  274. package/templates/node-express/files/src/tests/product.test.js +14 -0
  275. package/templates/node-express/files/src/utils/index.js +78 -0
  276. package/templates/node-express/files/src/utils/reasonPhrases.js +344 -0
  277. package/templates/node-express/files/src/utils/statusCodes.js +344 -0
  278. package/templates/node-express/files/src/utils/template.html.js +38 -0
  279. package/templates/node-express/template.json +11 -0
  280. package/templates/react-fsd/files/index.html +13 -0
  281. package/templates/react-fsd/files/package.json +15 -0
  282. package/templates/react-fsd/files/public/favicon.svg +1 -0
  283. package/templates/react-fsd/files/public/icons.svg +24 -0
  284. package/templates/react-fsd/files/src/assets/hero.png +0 -0
  285. package/templates/react-fsd/files/src/assets/typescript.svg +1 -0
  286. package/templates/react-fsd/files/src/assets/vite.svg +1 -0
  287. package/templates/react-fsd/files/src/counter.ts +9 -0
  288. package/templates/react-fsd/files/src/main.ts +60 -0
  289. package/templates/react-fsd/files/src/style.css +296 -0
  290. package/templates/react-fsd/files/tsconfig.json +24 -0
  291. package/templates/react-fsd/template.json +11 -0
  292. package/templates/react-monolithic/files/index.html +13 -0
  293. package/templates/react-monolithic/files/package.json +15 -0
  294. package/templates/react-monolithic/files/public/favicon.svg +1 -0
  295. package/templates/react-monolithic/files/public/icons.svg +24 -0
  296. package/templates/react-monolithic/files/src/assets/hero.png +0 -0
  297. package/templates/react-monolithic/files/src/assets/typescript.svg +1 -0
  298. package/templates/react-monolithic/files/src/assets/vite.svg +1 -0
  299. package/templates/react-monolithic/files/src/counter.ts +9 -0
  300. package/templates/react-monolithic/files/src/main.ts +60 -0
  301. package/templates/react-monolithic/files/src/style.css +296 -0
  302. package/templates/react-monolithic/files/tsconfig.json +24 -0
  303. package/templates/react-monolithic/template.json +11 -0
  304. package/templates/vue-fsd/files/index.html +13 -0
  305. package/templates/vue-fsd/files/package.json +15 -0
  306. package/templates/vue-fsd/files/public/favicon.svg +1 -0
  307. package/templates/vue-fsd/files/public/icons.svg +24 -0
  308. package/templates/vue-fsd/files/src/assets/hero.png +0 -0
  309. package/templates/vue-fsd/files/src/assets/typescript.svg +1 -0
  310. package/templates/vue-fsd/files/src/assets/vite.svg +1 -0
  311. package/templates/vue-fsd/files/src/counter.ts +9 -0
  312. package/templates/vue-fsd/files/src/main.ts +60 -0
  313. package/templates/vue-fsd/files/src/style.css +296 -0
  314. package/templates/vue-fsd/files/tsconfig.json +24 -0
  315. package/templates/vue-fsd/template.json +11 -0
  316. package/templates/vue-monolithic/files/index.html +13 -0
  317. package/templates/vue-monolithic/files/package.json +15 -0
  318. package/templates/vue-monolithic/files/public/favicon.svg +1 -0
  319. package/templates/vue-monolithic/files/public/icons.svg +24 -0
  320. package/templates/vue-monolithic/files/src/assets/hero.png +0 -0
  321. package/templates/vue-monolithic/files/src/assets/typescript.svg +1 -0
  322. package/templates/vue-monolithic/files/src/assets/vite.svg +1 -0
  323. package/templates/vue-monolithic/files/src/counter.ts +9 -0
  324. package/templates/vue-monolithic/files/src/main.ts +60 -0
  325. package/templates/vue-monolithic/files/src/style.css +296 -0
  326. package/templates/vue-monolithic/files/tsconfig.json +24 -0
  327. package/templates/vue-monolithic/template.json +11 -0
@@ -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,9 @@
1
+ import { Global, Module } from '@nestjs/common';
2
+ import { LockService } from './lock.service.js';
3
+
4
+ @Global()
5
+ @Module({
6
+ providers: [LockService],
7
+ exports: [LockService],
8
+ })
9
+ export class LockModule {}
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import { Global, Module } from '@nestjs/common';
2
+ import { RedisService } from './redis.service.js';
3
+
4
+ @Global()
5
+ @Module({
6
+ providers: [RedisService],
7
+ exports: [RedisService],
8
+ })
9
+ export class RedisModule {}