mega-framework 0.1.0

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 (322) hide show
  1. package/.env +127 -0
  2. package/.env.example +186 -0
  3. package/.prettierrc.json +8 -0
  4. package/CHANGELOG.md +259 -0
  5. package/LICENSE +21 -0
  6. package/README.md +153 -0
  7. package/bin/mega-ws-hub.js +15 -0
  8. package/bin/mega.js +38 -0
  9. package/docker-compose.yml +201 -0
  10. package/eslint.config.js +57 -0
  11. package/infra/otel-collector-config.yaml +43 -0
  12. package/jsconfig.json +18 -0
  13. package/package.json +121 -0
  14. package/sample/crud/.env +18 -0
  15. package/sample/crud/.env.example +50 -0
  16. package/sample/crud/README.md +85 -0
  17. package/sample/crud/apps/main/app.config.js +114 -0
  18. package/sample/crud/apps/main/channels/chat-bus.js +115 -0
  19. package/sample/crud/apps/main/channels/chat-channel.js +145 -0
  20. package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
  21. package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
  22. package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
  23. package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
  24. package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
  25. package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
  26. package/sample/crud/apps/main/controllers/note-controller.js +116 -0
  27. package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
  28. package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
  29. package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
  30. package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
  31. package/sample/crud/apps/main/controllers/user-controller.js +34 -0
  32. package/sample/crud/apps/main/controllers/web-controller.js +137 -0
  33. package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
  34. package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
  35. package/sample/crud/apps/main/jobs/email-job.js +72 -0
  36. package/sample/crud/apps/main/locales/client/en.json +3 -0
  37. package/sample/crud/apps/main/locales/client/ko.json +3 -0
  38. package/sample/crud/apps/main/locales/server/en.json +316 -0
  39. package/sample/crud/apps/main/locales/server/ko.json +316 -0
  40. package/sample/crud/apps/main/middleware/web-auth.js +40 -0
  41. package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
  42. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
  43. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
  44. package/sample/crud/apps/main/models/note.js +71 -0
  45. package/sample/crud/apps/main/models/user.js +86 -0
  46. package/sample/crud/apps/main/public/css/app.css +101 -0
  47. package/sample/crud/apps/main/public/css/guide.css +137 -0
  48. package/sample/crud/apps/main/public/js/app.js +54 -0
  49. package/sample/crud/apps/main/public/js/perf.js +129 -0
  50. package/sample/crud/apps/main/public/js/theme-init.js +12 -0
  51. package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
  52. package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
  53. package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
  54. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  55. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  56. package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
  57. package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
  58. package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
  59. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
  60. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
  61. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
  62. package/sample/crud/apps/main/routes/auth.js +15 -0
  63. package/sample/crud/apps/main/routes/cron.js +14 -0
  64. package/sample/crud/apps/main/routes/guide.js +25 -0
  65. package/sample/crud/apps/main/routes/jobs.js +14 -0
  66. package/sample/crud/apps/main/routes/logs.js +28 -0
  67. package/sample/crud/apps/main/routes/metrics.js +13 -0
  68. package/sample/crud/apps/main/routes/notes.js +19 -0
  69. package/sample/crud/apps/main/routes/perf.js +47 -0
  70. package/sample/crud/apps/main/routes/redis.js +14 -0
  71. package/sample/crud/apps/main/routes/tracing.js +14 -0
  72. package/sample/crud/apps/main/routes/upload.js +16 -0
  73. package/sample/crud/apps/main/routes/users.js +54 -0
  74. package/sample/crud/apps/main/routes/web.js +23 -0
  75. package/sample/crud/apps/main/routes/worker.js +15 -0
  76. package/sample/crud/apps/main/routes/ws.js +30 -0
  77. package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
  78. package/sample/crud/apps/main/services/auth-service.js +74 -0
  79. package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
  80. package/sample/crud/apps/main/services/guide-service.js +145 -0
  81. package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
  82. package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
  83. package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
  84. package/sample/crud/apps/main/services/note-service.js +75 -0
  85. package/sample/crud/apps/main/services/perf-service.js +302 -0
  86. package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
  87. package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
  88. package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
  89. package/sample/crud/apps/main/services/user-service.js +65 -0
  90. package/sample/crud/apps/main/views/auth/login.ejs +57 -0
  91. package/sample/crud/apps/main/views/auth/register.ejs +71 -0
  92. package/sample/crud/apps/main/views/cron/index.ejs +92 -0
  93. package/sample/crud/apps/main/views/guide/index.ejs +24 -0
  94. package/sample/crud/apps/main/views/guide/page.ejs +64 -0
  95. package/sample/crud/apps/main/views/home.ejs +82 -0
  96. package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
  97. package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
  98. package/sample/crud/apps/main/views/logs/index.ejs +80 -0
  99. package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
  100. package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
  101. package/sample/crud/apps/main/views/notes/list.ejs +74 -0
  102. package/sample/crud/apps/main/views/notes/new.ejs +45 -0
  103. package/sample/crud/apps/main/views/perf/index.ejs +90 -0
  104. package/sample/crud/apps/main/views/redis/index.ejs +65 -0
  105. package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
  106. package/sample/crud/apps/main/views/upload/index.ejs +79 -0
  107. package/sample/crud/apps/main/views/users/edit.ejs +48 -0
  108. package/sample/crud/apps/main/views/users/list.ejs +81 -0
  109. package/sample/crud/apps/main/views/users/new.ejs +48 -0
  110. package/sample/crud/apps/main/views/worker/index.ejs +70 -0
  111. package/sample/crud/apps/main/views/ws/index.ejs +62 -0
  112. package/sample/crud/apps/main/workers/hash-worker.js +17 -0
  113. package/sample/crud/apps/main/workers/hash.task.js +22 -0
  114. package/sample/crud/ecosystem.config.cjs +9 -0
  115. package/sample/crud/mega.config.js +105 -0
  116. package/sample/crud/package-lock.json +5665 -0
  117. package/sample/crud/package.json +28 -0
  118. package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
  119. package/sample/crud/test/apps/main/auth-service.test.js +93 -0
  120. package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
  121. package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
  122. package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
  123. package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
  124. package/sample/crud/test/apps/main/email-job.test.js +76 -0
  125. package/sample/crud/test/apps/main/guide-service.test.js +68 -0
  126. package/sample/crud/test/apps/main/hash-task.test.js +30 -0
  127. package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
  128. package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
  129. package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
  130. package/sample/crud/test/apps/main/note-service.test.js +68 -0
  131. package/sample/crud/test/apps/main/perf-service.test.js +121 -0
  132. package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
  133. package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
  134. package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
  135. package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
  136. package/sample/crud/test/apps/main/user-service.test.js +65 -0
  137. package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
  138. package/sample/crud/vitest.config.js +8 -0
  139. package/sample/crud/yarn.lock +2142 -0
  140. package/sample/simple/.env.example +15 -0
  141. package/sample/simple/README.md +52 -0
  142. package/sample/simple/apps/main/app.config.js +35 -0
  143. package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
  144. package/sample/simple/apps/main/locales/client/en.json +3 -0
  145. package/sample/simple/apps/main/locales/client/ko.json +3 -0
  146. package/sample/simple/apps/main/locales/server/en.json +23 -0
  147. package/sample/simple/apps/main/locales/server/ko.json +23 -0
  148. package/sample/simple/apps/main/public/css/app.css +101 -0
  149. package/sample/simple/apps/main/public/hello.txt +1 -0
  150. package/sample/simple/apps/main/public/js/app.js +54 -0
  151. package/sample/simple/apps/main/public/js/theme-init.js +12 -0
  152. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  153. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  154. package/sample/simple/apps/main/routes/index.js +9 -0
  155. package/sample/simple/apps/main/routes/pages.js +12 -0
  156. package/sample/simple/apps/main/views/index.ejs +56 -0
  157. package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
  158. package/sample/simple/ecosystem.config.cjs +10 -0
  159. package/sample/simple/mega.config.js +27 -0
  160. package/sample/simple/package-lock.json +1851 -0
  161. package/sample/simple/package.json +25 -0
  162. package/sample/simple/test/apps/main/index.test.js +13 -0
  163. package/sample/simple/vitest.config.js +8 -0
  164. package/src/adapters/adapter-manager.js +305 -0
  165. package/src/adapters/adapter-options.js +208 -0
  166. package/src/adapters/file-adapter.js +350 -0
  167. package/src/adapters/file-session-adapter.js +363 -0
  168. package/src/adapters/index.js +38 -0
  169. package/src/adapters/maria-adapter.js +425 -0
  170. package/src/adapters/mega-adapter.js +511 -0
  171. package/src/adapters/mega-bus-adapter.js +81 -0
  172. package/src/adapters/mega-cache-adapter.js +94 -0
  173. package/src/adapters/mega-db-adapter.js +72 -0
  174. package/src/adapters/mega-lock-adapter.js +118 -0
  175. package/src/adapters/mega-log-sink-adapter.js +46 -0
  176. package/src/adapters/mega-session-adapter.js +72 -0
  177. package/src/adapters/mongo-adapter.js +396 -0
  178. package/src/adapters/nats-adapter.js +370 -0
  179. package/src/adapters/postgres-adapter.js +341 -0
  180. package/src/adapters/redis-adapter.js +331 -0
  181. package/src/adapters/redis-session-adapter.js +261 -0
  182. package/src/adapters/redlock-adapter.js +385 -0
  183. package/src/adapters/registry.js +157 -0
  184. package/src/adapters/sqlite-adapter.js +309 -0
  185. package/src/auth/index.js +103 -0
  186. package/src/cli/commands/console-cmd.js +56 -0
  187. package/src/cli/commands/new.js +101 -0
  188. package/src/cli/commands/routes.js +107 -0
  189. package/src/cli/commands/scaffold.js +120 -0
  190. package/src/cli/commands/test-cmd.js +45 -0
  191. package/src/cli/generators/index.js +368 -0
  192. package/src/cli/index.js +472 -0
  193. package/src/cli/template-engine.js +72 -0
  194. package/src/cli/ws-hub.js +582 -0
  195. package/src/core/ajv-mapper.js +80 -0
  196. package/src/core/boot.js +323 -0
  197. package/src/core/cluster-metrics.js +278 -0
  198. package/src/core/config-loader.js +115 -0
  199. package/src/core/config-validator.js +322 -0
  200. package/src/core/ctx-builder.js +253 -0
  201. package/src/core/envelope.js +88 -0
  202. package/src/core/error-mapper.js +116 -0
  203. package/src/core/formbody.js +69 -0
  204. package/src/core/hub-link.js +552 -0
  205. package/src/core/i18n.js +525 -0
  206. package/src/core/index.js +63 -0
  207. package/src/core/mega-app.js +1138 -0
  208. package/src/core/mega-cluster.js +232 -0
  209. package/src/core/mega-server.js +176 -0
  210. package/src/core/mega-service.js +41 -0
  211. package/src/core/migration-runner.js +196 -0
  212. package/src/core/multipart.js +282 -0
  213. package/src/core/openapi.js +114 -0
  214. package/src/core/router.js +388 -0
  215. package/src/core/routes-loader.js +57 -0
  216. package/src/core/scope-registry.js +53 -0
  217. package/src/core/security.js +275 -0
  218. package/src/core/services-loader.js +98 -0
  219. package/src/core/session-cleanup-schedule.js +57 -0
  220. package/src/core/session-store.js +55 -0
  221. package/src/core/session.js +414 -0
  222. package/src/core/static-assets.js +126 -0
  223. package/src/core/template.js +294 -0
  224. package/src/core/workers-manager.js +193 -0
  225. package/src/core/ws-compression.js +112 -0
  226. package/src/core/ws-controller.js +109 -0
  227. package/src/core/ws-message.js +176 -0
  228. package/src/core/ws-upgrade.js +445 -0
  229. package/src/errors/config-error.js +16 -0
  230. package/src/errors/http-errors.js +130 -0
  231. package/src/errors/index.js +19 -0
  232. package/src/errors/mega-error.js +34 -0
  233. package/src/eslint-plugin/index.js +15 -0
  234. package/src/eslint-plugin/no-direct-model-import.js +113 -0
  235. package/src/index.js +131 -0
  236. package/src/lib/asp/config.js +83 -0
  237. package/src/lib/asp/crypto.js +145 -0
  238. package/src/lib/asp/errors.js +49 -0
  239. package/src/lib/asp/nonce-cache.js +94 -0
  240. package/src/lib/asp/plugin.js +263 -0
  241. package/src/lib/asp/ws-terminator.js +101 -0
  242. package/src/lib/env-mapper.js +222 -0
  243. package/src/lib/hub-protocol.js +322 -0
  244. package/src/lib/index.js +42 -0
  245. package/src/lib/logger/telegram-core.js +150 -0
  246. package/src/lib/logger/telegram-transport.js +126 -0
  247. package/src/lib/mega-brute-force.js +225 -0
  248. package/src/lib/mega-circuit-breaker.js +412 -0
  249. package/src/lib/mega-cron.js +169 -0
  250. package/src/lib/mega-hash.js +179 -0
  251. package/src/lib/mega-health.js +91 -0
  252. package/src/lib/mega-job-queue.js +600 -0
  253. package/src/lib/mega-job-worker.js +295 -0
  254. package/src/lib/mega-job.js +140 -0
  255. package/src/lib/mega-logger.js +128 -0
  256. package/src/lib/mega-metrics.js +661 -0
  257. package/src/lib/mega-plugin.js +650 -0
  258. package/src/lib/mega-retry.js +95 -0
  259. package/src/lib/mega-schedule.js +507 -0
  260. package/src/lib/mega-shutdown.js +176 -0
  261. package/src/lib/mega-tracing.js +715 -0
  262. package/src/lib/mega-worker.js +653 -0
  263. package/src/lib/worker-runner/process-entry.js +30 -0
  264. package/src/lib/worker-runner/task-dispatch.js +72 -0
  265. package/src/lib/worker-runner/thread-entry.js +26 -0
  266. package/src/models/index.js +7 -0
  267. package/src/models/mega-model.js +151 -0
  268. package/src/test/index.js +288 -0
  269. package/templates/adapter/code.tpl +40 -0
  270. package/templates/adapter/test.tpl +13 -0
  271. package/templates/app/app.config.tpl +10 -0
  272. package/templates/app/route.tpl +10 -0
  273. package/templates/app/test.tpl +13 -0
  274. package/templates/channel/code.tpl +38 -0
  275. package/templates/channel/test.tpl +19 -0
  276. package/templates/controller/code.tpl +16 -0
  277. package/templates/controller/route.tpl +9 -0
  278. package/templates/controller/test.tpl +14 -0
  279. package/templates/job/code.tpl +23 -0
  280. package/templates/job/test.tpl +17 -0
  281. package/templates/locale/code.tpl +3 -0
  282. package/templates/locale/test.tpl +13 -0
  283. package/templates/middleware/code.tpl +13 -0
  284. package/templates/middleware/test.tpl +11 -0
  285. package/templates/migration/code.tpl +20 -0
  286. package/templates/migration/test.tpl +14 -0
  287. package/templates/model/code.tpl +21 -0
  288. package/templates/model/test.tpl +29 -0
  289. package/templates/project/app.config.tpl +8 -0
  290. package/templates/project/app.config.views.tpl +37 -0
  291. package/templates/project/ecosystem.config.tpl +10 -0
  292. package/templates/project/env.tpl +12 -0
  293. package/templates/project/gitignore.tpl +8 -0
  294. package/templates/project/locales/client/en.json.tpl +3 -0
  295. package/templates/project/locales/client/ko.json.tpl +3 -0
  296. package/templates/project/locales/server/en.json.tpl +17 -0
  297. package/templates/project/locales/server/ko.json.tpl +17 -0
  298. package/templates/project/mega.config.tpl +11 -0
  299. package/templates/project/package.tpl +25 -0
  300. package/templates/project/public/css/app.css +101 -0
  301. package/templates/project/public/js/app.js +54 -0
  302. package/templates/project/public/js/theme-init.js +12 -0
  303. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  304. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
  305. package/templates/project/readme.tpl +48 -0
  306. package/templates/project/route.test.tpl +13 -0
  307. package/templates/project/route.test.views.tpl +15 -0
  308. package/templates/project/route.tpl +10 -0
  309. package/templates/project/route.views.tpl +10 -0
  310. package/templates/project/views/index.ejs.tpl +58 -0
  311. package/templates/project/views/layout.ejs.tpl +73 -0
  312. package/templates/project/vitest.config.tpl +8 -0
  313. package/templates/route/code.tpl +11 -0
  314. package/templates/route/test.tpl +26 -0
  315. package/templates/schedule/code.tpl +19 -0
  316. package/templates/schedule/test.tpl +17 -0
  317. package/templates/service/code.tpl +18 -0
  318. package/templates/service/test.tpl +17 -0
  319. package/templates/worker/code.tpl +14 -0
  320. package/templates/worker/task.tpl +13 -0
  321. package/templates/worker/test.tpl +18 -0
  322. package/vitest.config.js +33 -0
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaRetry — exponential backoff 재시도 유틸 (ADR-029/ADR-098).
4
+ *
5
+ * 검증된 공개 라이브러리 `p-retry`(v8) 를 우리 컨벤션으로 얇게 래핑한다(ADR-029: "내부 구현은
6
+ * 검증된 공개 npm 라이브러리를 래핑한다 — MegaRetry=p-retry"). 직접 백오프 타이머를 굴리지 않는
7
+ * 이유: 지터·취소(AbortSignal)·시도별 콜백 같은 경계 케이스를 직접 짜면 추론 최소화 원칙 위반이
8
+ * 되기 쉽다. p-retry 는 이를 검증된 형태로 제공한다.
9
+ *
10
+ * # 백오프 공식 (p-retry)
11
+ * delay(attempt) = min(maxTimeout, random * minTimeout * factor^(attempt-1))
12
+ * - `jitter: true` → random ∈ [1, 2) 곱(p-retry `randomize`). thundering herd 완화.
13
+ * - `jitter: false` → 정확히 minTimeout * factor^(attempt-1) (테스트 결정성).
14
+ *
15
+ * # 첫 시도는 즉시
16
+ * attempt 1 은 지연 없이 실행된다. `retries: N` 이면 최대 N 회 **추가** 재시도(총 N+1 시도).
17
+ *
18
+ * # 재시도 중단 (AbortError)
19
+ * {@link RetryAbortError} 를 throw 하면 즉시 중단되고 그 안에 감싼 원본 에러가 다시 던져진다.
20
+ * "이 에러는 재시도해도 소용없다"(예: 인증 실패) 를 표현할 때 쓴다.
21
+ *
22
+ * # 에러 처리 정합
23
+ * 본 모듈은 에러를 묵지 않는다 — 최종 실패 시 마지막 에러를 그대로 throw 한다. 호출부가
24
+ * try/catch 로 명시 처리 하도록 한다.
25
+ *
26
+ * @module lib/mega-retry
27
+ */
28
+ import pRetry, { AbortError } from 'p-retry'
29
+
30
+ /**
31
+ * 재시도를 즉시 중단시키는 에러 래퍼 (p-retry `AbortError`). `throw new RetryAbortError(원본에러)`
32
+ * 하면 더 이상 재시도하지 않고 원본 에러가 호출부로 전파된다.
33
+ * @type {typeof AbortError}
34
+ */
35
+ export const RetryAbortError = AbortError
36
+
37
+ /**
38
+ * @typedef {Object} MegaRetryOptions
39
+ * @property {number} [retries=10] - 최대 **추가** 재시도 횟수(첫 시도 제외). Infinity 허용.
40
+ * @property {number} [minTimeout=1000] - 첫 백오프 지연(ms).
41
+ * @property {number} [maxTimeout=30000] - 백오프 지연 상한(ms).
42
+ * @property {number} [factor=2] - 지수 증가율.
43
+ * @property {boolean} [jitter=true] - 지연에 random[1,2) 곱(thundering herd 완화). false 면 결정적.
44
+ * @property {(ctx: { error: Error, attemptNumber: number, retriesLeft: number, retryDelay: number }) => void|Promise<void>} [onFailedAttempt]
45
+ * - 매 시도 실패 직후 호출(다음 지연 전). 로그/관측용. throw 하면 재시도 중단.
46
+ * @property {(ctx: { error: Error, attemptNumber: number, retriesLeft: number }) => boolean|Promise<boolean>} [shouldRetry]
47
+ * - false 반환 시 재시도 중단(마지막 에러 throw). 특정 에러만 재시도할 때.
48
+ * @property {AbortSignal} [signal] - abort 시 대기/재시도 즉시 취소.
49
+ * @property {boolean} [unref=true] - 백오프 타이머 unref(프로세스 종료를 막지 않음). 기본 true.
50
+ */
51
+
52
+ /**
53
+ * `fn` 을 지수 백오프로 재시도한다. 성공 시 그 반환값을, 모두 실패하면 마지막 에러를 throw.
54
+ *
55
+ * @template T
56
+ * @param {(attemptNumber: number) => Promise<T>|T} fn - 실행할 작업. attemptNumber(1-based) 를 받는다.
57
+ * @param {MegaRetryOptions} [options]
58
+ * @returns {Promise<T>}
59
+ * @throws {Error} 모든 시도 실패 시 마지막 에러(또는 RetryAbortError 의 원본 에러).
60
+ * @example
61
+ * await withRetry(() => connectToHub(), { retries: 5, minTimeout: 500, maxTimeout: 10_000 })
62
+ */
63
+ export function withRetry(fn, options = {}) {
64
+ const {
65
+ retries = 10,
66
+ minTimeout = 1000,
67
+ maxTimeout = 30_000,
68
+ factor = 2,
69
+ jitter = true,
70
+ onFailedAttempt,
71
+ shouldRetry,
72
+ signal,
73
+ unref = true,
74
+ } = options
75
+
76
+ // jitter → p-retry `randomize`. 나머지는 1:1 매핑. 콜백/signal 은 있을 때만 실어 p-retry 기본 유지.
77
+ // p-retry 의 Options 타입은 readonly 라 한 번에 리터럴로 구성한다(이후 변형 X).
78
+ /** @type {import('p-retry').Options} */
79
+ const pOpts = {
80
+ retries,
81
+ minTimeout,
82
+ maxTimeout,
83
+ factor,
84
+ randomize: jitter,
85
+ unref,
86
+ ...(typeof onFailedAttempt === 'function' ? { onFailedAttempt } : {}),
87
+ ...(typeof shouldRetry === 'function' ? { shouldRetry } : {}),
88
+ ...(signal ? { signal } : {}),
89
+ }
90
+
91
+ return pRetry(fn, pOpts)
92
+ }
93
+
94
+ /** 네임스페이스 형태 접근(다른 Mega* 유틸과 일관). `MegaRetry.withRetry(...)`. */
95
+ export const MegaRetry = Object.freeze({ withRetry, RetryAbortError })
@@ -0,0 +1,507 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaSchedule / MegaScheduler — cron 기반 작업 스케줄러 + 분산 중복 실행 방지 (ADR-118).
4
+ *
5
+ * 두 가지를 제공한다:
6
+ * - {@link MegaSchedule} — 스케줄 작업 **베이스 클래스**(static 설정 + `run(ctx)`). 03-api-spec §6 의
7
+ * "클래스 + static 설정" 패턴(MegaJob/MegaWorker 와 동형).
8
+ * - {@link MegaScheduler} — 등록된 스케줄들을 실제 cron 시각에 **실행**하는 런타임. 시각 계산·타이머는
9
+ * `croner`(v10) 에 위임하고(MegaCron 과 동일 라이브러리), **분산 중복방지**는
10
+ * `MegaLockAdapter`(redlock, `ctx.lock(alias)`)에 위임한다.
11
+ *
12
+ * # 분산 중복 실행 방지 (왜 필요한가 — 중학생용)
13
+ * 같은 앱을 서버 2대(또는 2 프로세스)에 띄우면, "매일 새벽 3시 정리" 같은 스케줄이 **양쪽에서 동시에**
14
+ * 돈다 → 정리가 2번 실행돼 데이터가 꼬일 수 있다. 그래서 실행 직전에 **분산 락**을 딱 1개만 잡게 한다:
15
+ * 먼저 잡은 1대만 실행하고, 못 잡은 나머지는 **조용히 건너뛴다**(skip). 락은 `ttl`(ms) 뒤 자동 만료돼
16
+ * 다음 주기엔 다시 경쟁한다.
17
+ *
18
+ * 구현: `lock.acquire(key, { ttl, retryCount: 0 })` — **단 한 번** 시도하고 실패하면(이미 누가 잡음)
19
+ * 즉시 skip 한다(retry 안 함 — retry+throw 는 "중복방지"가 아니라 "대기"라서 의미가 다름). 성공하면
20
+ * `run(ctx)` 실행 후 **반드시 release**(finally).
21
+ *
22
+ * # ⚠️ acquire 실패 = skip 의 한계 (ADR-118 기록)
23
+ * `acquire` 는 (a) 경합(다른 인스턴스가 보유)과 (b) 락 backend 장애(Redis 다운)를 **둘 다** throw 로
24
+ * 알린다 — 현재 lock 어댑터 계약상 둘을 typed-error 로 구분하지 못한다. 그래서 본 스케줄러는 acquire
25
+ * 실패를 모두 skip 으로 보되, **에러를 `skip` 이벤트에 실어** 관측 가능하게 한다(소비자가 로그로
26
+ * 인프라 장애를 알아챌 수 있게). 부팅 시 `connectAll({ ping: true })` 가 backend 가용성을 1차 검증한다.
27
+ * 런타임 장애 구분(skip vs fail)을 위한 lock 어댑터 typed-error 는 후속 과제(ADR-118 재평가 조건).
28
+ *
29
+ * # 순수 유틸이라 logger 미주입, 이벤트로 노출
30
+ * MegaCircuitBreaker 와 동일하게 본 스케줄러는 logger 를 모른다. 대신 흐름 길목(job 시작/종료)을
31
+ * 이벤트로 노출한다: `run`(실행 시작)·`done`(성공)·`skip`(락 미획득→건너뜀)·`fail`(실행/해제 실패).
32
+ * 소비자(앱·CLI)가 이 이벤트를 구독해 `logger.debug/warn` 을 박는다. **타이머로 자동 실행되는 경로는
33
+ * 호출자가 없으므로, 실패를 놓치지 않으려면 `fail`/`skip` 구독이 사실상 필수다.**
34
+ *
35
+ * @module lib/mega-schedule
36
+ * @see ADR-118, ADR-113 (MegaLock/redlock), ADR-029 (라이브러리 래핑)
37
+ */
38
+ import { Cron } from 'croner'
39
+ import { EventEmitter } from 'node:events'
40
+ import { MegaConfigError } from '../errors/config-error.js'
41
+ import { MegaCron } from './mega-cron.js'
42
+
43
+ /**
44
+ * 스케줄 분산 락 설정. **lock 도메인은 cache 와 별개**(ADR-113) — `ctx.lock(alias)`.
45
+ *
46
+ * @typedef {Object} MegaScheduleLock
47
+ * @property {string} lock - lock 어댑터 별명(`ctx.lock(alias)` 로 해석). 03-api-spec 의 옛 `cache` 필드를
48
+ * 대체한다(lock 이 독립 도메인이 된 뒤 정합 — ADR-118).
49
+ * @property {number} ttl - 락 보유 시간(**밀리초**, 양의 정수). **작업 예상 소요보다 넉넉히** 잡아야
50
+ * 한다 — 작업이 ttl 을 넘기면 락이 자동 만료돼 다른 인스턴스가 중복 실행할 수 있다(자동 연장 미사용).
51
+ * @property {string} [key] - 락 자원 키. 미지정 시 `mega:schedule:<클래스명>`.
52
+ */
53
+
54
+ /**
55
+ * 스케줄 작업 베이스 클래스. 서브클래스가 static 설정과 `run(ctx)` 를 정의한다.
56
+ *
57
+ * @example
58
+ * export class DailyCleanup extends MegaSchedule {
59
+ * static cron = '0 3 * * *'
60
+ * static timezone = 'Asia/Seoul'
61
+ * static lock = { lock: 'main', ttl: 60_000 } // 분산 중복방지 (ms!)
62
+ * async run(ctx) {
63
+ * await ctx.db('main').native.query('DELETE FROM tmp WHERE ...')
64
+ * }
65
+ * }
66
+ */
67
+ export class MegaSchedule {
68
+ /** @type {string|undefined} cron 표현식(필수 — 미정의 시 register 에서 throw). */
69
+ static cron = undefined
70
+
71
+ /** @type {string|undefined} IANA 타임존(예: 'Asia/Seoul'). 미지정 시 호스트 로컬. */
72
+ static timezone = undefined
73
+
74
+ /** @type {MegaScheduleLock|undefined} 분산 중복방지 락 설정. 미지정 시 중복방지 없이 실행. */
75
+ static lock = undefined
76
+
77
+ /**
78
+ * 스케줄 시각마다 실행되는 본문. 서브클래스가 **반드시** 구현한다.
79
+ * @param {Record<string, any>} _ctx - 실행 컨텍스트(`db/cache/bus/lock` 접근자 등, ctx-builder 참조).
80
+ * @returns {Promise<any>}
81
+ * @throws {Error} 서브클래스가 구현하지 않으면.
82
+ */
83
+ async run(_ctx) {
84
+ throw new Error(
85
+ `${this.constructor.name}: MegaSchedule subclass must implement async run(ctx).`,
86
+ )
87
+ }
88
+ }
89
+
90
+ /** 스케줄러가 노출하는 이벤트 화이트리스트(오타 차단 + 문서화 — MegaCircuitBreaker 와 동일 정책). */
91
+ const KNOWN_EVENTS = Object.freeze([
92
+ 'run', // 작업 실행 시작(락 획득 후, 또는 락 미사용)
93
+ 'skip', // 락 미획득(다른 인스턴스가 보유 등)으로 건너뜀 — event.error 에 사유
94
+ 'done', // 작업 성공 완료
95
+ 'fail', // 작업 run() 실패 또는 락 release 실패 — event.error / event.phase
96
+ ])
97
+
98
+ /**
99
+ * @typedef {Object} MegaScheduleFireResult - {@link MegaScheduler#runNow} / 내부 fire 의 결과.
100
+ * @property {string} name - 스케줄 클래스명.
101
+ * @property {boolean} ran - 실제로 run(ctx) 가 호출됐는지(skip 이면 false).
102
+ * @property {boolean} skipped - 락 미획득으로 건너뛰었는지.
103
+ * @property {boolean} ok - run(ctx) 가 에러 없이 끝났는지(skip 이면 false).
104
+ * @property {any} [result] - run(ctx) 반환값(성공 시).
105
+ * @property {Error} [error] - 실패/skip 사유.
106
+ */
107
+
108
+ /**
109
+ * @typedef {Object} ScheduleEntry - 등록된 스케줄 1건의 내부 메타데이터.
110
+ * @property {string} name - 스케줄 클래스명(= 등록 키).
111
+ * @property {typeof MegaSchedule} ScheduleClass - 등록된 클래스.
112
+ * @property {MegaSchedule} instance - 인스턴스(run 호출 대상).
113
+ * @property {string} cron - cron 표현식.
114
+ * @property {string|undefined} timezone - IANA 타임존.
115
+ * @property {MegaScheduleLock|undefined} lock - 분산 락 설정.
116
+ * @property {Cron|null} job - start() 가 만든 croner 핸들(미 start 면 null).
117
+ */
118
+
119
+ /**
120
+ * 등록된 스케줄들을 cron 시각에 실행하는 런타임. 시각/타이머는 croner, 분산 중복방지는 lock 어댑터에 위임.
121
+ *
122
+ * @example
123
+ * const scheduler = new MegaScheduler({ ctx }) // ctx 는 lock 사용 시 ctx.lock(alias) 필요
124
+ * scheduler.on('skip', (e) => log.debug(e, 'schedule skipped'))
125
+ * scheduler.on('fail', (e) => log.error(e, 'schedule failed'))
126
+ * scheduler.register(DailyCleanup).start()
127
+ * // graceful shutdown
128
+ * scheduler.stop()
129
+ */
130
+ export class MegaScheduler extends EventEmitter {
131
+ /** @type {Record<string, any>|null} run(ctx) 에 넘길 컨텍스트(+ lock 해석용). */
132
+ #ctx
133
+ /** @type {Map<string, ScheduleEntry>} 등록된 스케줄(이름 → 메타 + croner 핸들). */
134
+ #jobs = new Map()
135
+ /** @type {boolean} start() 호출 후 stop() 전까지 true. */
136
+ #started = false
137
+
138
+ /**
139
+ * @param {object} [args]
140
+ * @param {Record<string, any>} [args.ctx] - 실행 컨텍스트. 스케줄이 `static lock` 을 쓰면 `ctx.lock`
141
+ * (함수)이 있어야 한다(없으면 fire 시 throw). lock 미사용이면 생략 가능.
142
+ */
143
+ constructor({ ctx } = {}) {
144
+ super()
145
+ this.#ctx = ctx ?? null
146
+ }
147
+
148
+ /**
149
+ * 이벤트명 화이트리스트 검증. 모든 구독/해제 오버라이드가 공유한다 — 오타 이벤트명을 조용히
150
+ * 무시하지 않고 즉시 RangeError 로 드러낸다(L-1: on/off 뿐 아니라 once/addListener/prepend* 도 동일 보호).
151
+ * @param {string} method - 호출된 메서드명(에러 메시지용).
152
+ * @param {string} event - 검증할 이벤트명.
153
+ * @returns {void}
154
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
155
+ */
156
+ #assertKnownEvent(method, event) {
157
+ if (!KNOWN_EVENTS.includes(event)) {
158
+ throw new RangeError(
159
+ `MegaScheduler.${method}('${event}', ...) — unknown event. Known: ${KNOWN_EVENTS.join(', ')}.`,
160
+ )
161
+ }
162
+ }
163
+
164
+ /**
165
+ * 이벤트 구독. 알 수 없는 이벤트명은 오타로 보고 throw(MegaCircuitBreaker 와 동일).
166
+ * @param {'run'|'skip'|'done'|'fail'} event @param {(payload: any) => void} listener @returns {this}
167
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
168
+ */
169
+ on(event, listener) {
170
+ this.#assertKnownEvent('on', event)
171
+ return super.on(event, listener)
172
+ }
173
+
174
+ /**
175
+ * {@link MegaScheduler#on} 으로 등록한 리스너 해제. 동일 화이트리스트 검증. (`removeListener` 별칭도 보호.)
176
+ * @param {'run'|'skip'|'done'|'fail'} event @param {(payload: any) => void} listener @returns {this}
177
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
178
+ */
179
+ off(event, listener) {
180
+ this.#assertKnownEvent('off', event)
181
+ return super.off(event, listener)
182
+ }
183
+
184
+ /**
185
+ * 1회성 구독(EventEmitter `once`). on() 과 동일 화이트리스트(L-1).
186
+ * @param {'run'|'skip'|'done'|'fail'} event @param {(payload: any) => void} listener @returns {this}
187
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
188
+ */
189
+ once(event, listener) {
190
+ this.#assertKnownEvent('once', event)
191
+ return super.once(event, listener)
192
+ }
193
+
194
+ /**
195
+ * `on` 의 EventEmitter 별칭. 우회로 화이트리스트를 비껴가지 않도록 동일 검증(L-1).
196
+ * @param {'run'|'skip'|'done'|'fail'} event @param {(payload: any) => void} listener @returns {this}
197
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
198
+ */
199
+ addListener(event, listener) {
200
+ this.#assertKnownEvent('addListener', event)
201
+ return super.addListener(event, listener)
202
+ }
203
+
204
+ /**
205
+ * `off` 의 EventEmitter 별칭. 동일 화이트리스트 검증(L-1).
206
+ * @param {'run'|'skip'|'done'|'fail'} event @param {(payload: any) => void} listener @returns {this}
207
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
208
+ */
209
+ removeListener(event, listener) {
210
+ this.#assertKnownEvent('removeListener', event)
211
+ return super.removeListener(event, listener)
212
+ }
213
+
214
+ /**
215
+ * 리스너를 큐 앞에 추가(EventEmitter `prependListener`). 동일 화이트리스트 검증(L-1).
216
+ * @param {'run'|'skip'|'done'|'fail'} event @param {(payload: any) => void} listener @returns {this}
217
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
218
+ */
219
+ prependListener(event, listener) {
220
+ this.#assertKnownEvent('prependListener', event)
221
+ return super.prependListener(event, listener)
222
+ }
223
+
224
+ /**
225
+ * 1회성 리스너를 큐 앞에 추가(EventEmitter `prependOnceListener`). 동일 화이트리스트 검증(L-1).
226
+ * @param {'run'|'skip'|'done'|'fail'} event @param {(payload: any) => void} listener @returns {this}
227
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
228
+ */
229
+ prependOnceListener(event, listener) {
230
+ this.#assertKnownEvent('prependOnceListener', event)
231
+ return super.prependOnceListener(event, listener)
232
+ }
233
+
234
+ /**
235
+ * 스케줄 클래스를 등록한다(아직 실행 안 함 — {@link MegaScheduler#start} 가 타이머를 건다).
236
+ * cron 표현식·lock 설정을 즉시 검증해 잘못된 등록은 부팅 시점에 드러낸다(추측 진행 금지).
237
+ *
238
+ * @param {typeof MegaSchedule} ScheduleClass - `MegaSchedule` 를 상속한 클래스.
239
+ * @returns {this} 체이닝용.
240
+ * @throws {TypeError} MegaSchedule 서브클래스가 아니거나 cron 이 비었을 때.
241
+ * @throws {Error} cron 표현식 무효, 이름 중복, lock 설정 형식 오류 시.
242
+ */
243
+ register(ScheduleClass) {
244
+ if (typeof ScheduleClass !== 'function' || !(ScheduleClass.prototype instanceof MegaSchedule)) {
245
+ throw new TypeError('MegaScheduler.register(C) — C must be a subclass of MegaSchedule.')
246
+ }
247
+ const name = ScheduleClass.name
248
+ if (typeof name !== 'string' || name.length === 0) {
249
+ throw new TypeError('MegaScheduler.register(C) — schedule class must have a non-empty name.')
250
+ }
251
+ if (this.#jobs.has(name)) {
252
+ throw new Error(`MegaScheduler.register: schedule '${name}' is already registered.`)
253
+ }
254
+ const cron = ScheduleClass.cron
255
+ if (typeof cron !== 'string' || cron.trim() === '') {
256
+ throw new TypeError(
257
+ `MegaScheduler.register('${name}') — static cron must be a non-empty string.`,
258
+ )
259
+ }
260
+ const timezone = ScheduleClass.timezone
261
+ // cron + timezone 정합을 즉시 검증(무효면 throw).
262
+ MegaCron.validate(cron, { timezone })
263
+ // timezone 미지정/빈 문자열 → 호스트 로컬 시각 폴백(환경 의존). 부팅 시 1회 경고(L-3).
264
+ if (typeof timezone !== 'string' || timezone.trim() === '') {
265
+ console.warn(
266
+ `[mega-schedule] '${name}' has no static timezone — falling back to host local time. ` +
267
+ `Set 'static timezone = "Asia/Seoul"' (IANA) to avoid environment-dependent behavior.`,
268
+ )
269
+ }
270
+
271
+ const lock = ScheduleClass.lock
272
+ if (lock !== undefined) {
273
+ this.#assertLockConfig(name, lock)
274
+ // lock 별명을 부팅 시점에 해석해 미선언 별명을 fail-fast(M-1). ctx.lock 미주입이면 이 시점엔
275
+ // 검증 불가 — fire 시 fail(lock-setup) 로 표면화(기존 계약 유지).
276
+ this.#assertLockAliasDeclared(name, lock.lock)
277
+ }
278
+
279
+ const instance = new ScheduleClass()
280
+ this.#jobs.set(name, { name, ScheduleClass, instance, cron, timezone, lock, job: null })
281
+ return this
282
+ }
283
+
284
+ /**
285
+ * lock 설정 형식 검증(fail-fast). lock 도메인 ttl 은 **밀리초 양의 정수**(어댑터 `_assertLockTtl` 정합).
286
+ * @param {string} name @param {any} lock @returns {void}
287
+ */
288
+ #assertLockConfig(name, lock) {
289
+ if (lock === null || typeof lock !== 'object') {
290
+ throw new Error(
291
+ `MegaScheduler.register('${name}') — static lock must be an object { lock, ttl, key? }.`,
292
+ )
293
+ }
294
+ if (typeof lock.lock !== 'string' || lock.lock.length === 0) {
295
+ throw new Error(
296
+ `MegaScheduler.register('${name}') — lock.lock must be a non-empty string (the ctx.lock(alias) name).`,
297
+ )
298
+ }
299
+ if (typeof lock.ttl !== 'number' || !Number.isInteger(lock.ttl) || lock.ttl <= 0) {
300
+ throw new Error(
301
+ `MegaScheduler.register('${name}') — lock.ttl must be a positive integer (milliseconds). Got: ${lock.ttl}.`,
302
+ )
303
+ }
304
+ if (lock.key !== undefined && (typeof lock.key !== 'string' || lock.key.length === 0)) {
305
+ throw new Error(
306
+ `MegaScheduler.register('${name}') — lock.key, if set, must be a non-empty string.`,
307
+ )
308
+ }
309
+ }
310
+
311
+ /**
312
+ * lock 별명이 ctx 에 선언돼 있는지 부팅 시점에 검증한다(M-1 fail-fast). `ctx.lock(alias)` 를 1회
313
+ * 호출해 미선언 별명이면 즉시 throw — 잘못된 별명 오타가 **첫 cron 트리거 때(타이머·호출자 없음)** 까지
314
+ * 숨지 않게 한다. ctx.lock 이 미주입(스케줄러가 lock 없이 생성)이면 이 시점엔 검증할 수 없으므로
315
+ * 건너뛴다 — 그 경우는 fire 시 `fail(lock-setup)` 으로 표면화된다(`#fire` 의 동일 가드).
316
+ * @param {string} name @param {string} alias @returns {void}
317
+ * @throws {MegaConfigError} ctx.lock 이 있는데 별명이 미선언일 때.
318
+ */
319
+ #assertLockAliasDeclared(name, alias) {
320
+ const ctx = this.#ctx
321
+ if (!ctx || typeof ctx.lock !== 'function') return // 미주입 — fire 시 fail(lock-setup) 로 처리.
322
+ try {
323
+ ctx.lock(alias) // 미선언 별명이면 ctx-builder 가 throw(MegaConfigError) — 부팅에서 잡는다.
324
+ } catch (e) {
325
+ const cause = e instanceof Error ? e : new Error(String(e))
326
+ throw new MegaConfigError(
327
+ 'schedule.unknown_lock_alias',
328
+ `MegaScheduler.register('${name}') — lock alias '${alias}' is not a declared lock adapter. ` +
329
+ `Declare it in the app's locks config (ctx.lock('${alias}') failed: ${cause.message}).`,
330
+ { details: { schedule: name, alias }, cause },
331
+ )
332
+ }
333
+ }
334
+
335
+ /**
336
+ * 등록된 모든 스케줄에 croner 타이머를 걸어 cron 시각마다 자동 실행한다. 이미 start 된 스케줄은 건너뛴다.
337
+ *
338
+ * croner 옵션: `protect`(같은 인스턴스에서 이전 실행이 안 끝났으면 새 트리거 차단 — 단일 프로세스 overrun
339
+ * 방지), `unref`(타이머가 프로세스 종료를 막지 않음 — graceful/테스트), `catch`(타이머 콜백의 미처리
340
+ * 에러를 `fail` 이벤트로 라우팅 — unhandled rejection 방지).
341
+ *
342
+ * @returns {this} 체이닝용.
343
+ */
344
+ start() {
345
+ for (const job of this.#jobs.values()) {
346
+ if (job.job !== null) continue // 이미 스케줄됨.
347
+ job.job = new Cron(
348
+ job.cron,
349
+ {
350
+ name: job.name,
351
+ timezone: job.timezone,
352
+ protect: true,
353
+ unref: true,
354
+ catch: (/** @type {unknown} */ e) => {
355
+ const error = e instanceof Error ? e : new Error(String(e))
356
+ this.emit('fail', { name: job.name, error, phase: 'timer' })
357
+ },
358
+ },
359
+ // croner 콜백은 void|Promise<void> 계약 — #fire 결과는 버리고(이벤트로 이미 노출) await 만 한다.
360
+ async () => {
361
+ await this.#fire(job.name)
362
+ },
363
+ )
364
+ }
365
+ this.#started = true
366
+ return this
367
+ }
368
+
369
+ /**
370
+ * 모든 스케줄 타이머를 영구 중단한다(graceful shutdown 필수 — 안 부르면 타이머가 프로세스 종료를
371
+ * 늦출 수 있다). croner `stop()` 후엔 같은 인스턴스를 resume 할 수 없어, job 핸들을 비우고 재 start 시
372
+ * 새로 만든다. 진행 중인 run(ctx) 은 중단하지 않는다(완료까지 대기는 호출부 책임).
373
+ * @returns {this} 체이닝용.
374
+ */
375
+ stop() {
376
+ for (const job of this.#jobs.values()) {
377
+ if (job.job !== null) {
378
+ job.job.stop()
379
+ job.job = null
380
+ }
381
+ }
382
+ this.#started = false
383
+ return this
384
+ }
385
+
386
+ /**
387
+ * 스케줄을 **지금 즉시** 한 번 실행한다(cron 시각 무시). 분산 중복방지 로직은 그대로 적용된다 —
388
+ * 테스트·운영 수동 트리거·CLI 용.
389
+ *
390
+ * ⚠️ overlap: lock 미사용 스케줄은 `runNow` 호출 시 croner 의 자동 트리거와 **동일 프로세스 내에서
391
+ * overlap** 할 수 있다(croner `protect` 는 타이머 트리거끼리만 막고 수동 `runNow` 는 막지 않는다).
392
+ * 분산/동일프로세스 중복방지가 필요한 작업은 `static lock` 을 선언하라(L-2).
393
+ * @param {string} name - 등록된 스케줄 클래스명.
394
+ * @returns {Promise<MegaScheduleFireResult>}
395
+ * @throws {Error} 등록되지 않은 name 일 때.
396
+ */
397
+ async runNow(name) {
398
+ if (!this.#jobs.has(name)) {
399
+ throw new Error(
400
+ `MegaScheduler.runNow('${name}') — not registered. Registered: [${
401
+ this.list()
402
+ .map((j) => j.name)
403
+ .join(', ') || '(none)'
404
+ }].`,
405
+ )
406
+ }
407
+ return this.#fire(name)
408
+ }
409
+
410
+ /**
411
+ * 등록된 스케줄 메타데이터 목록. 모니터링/CLI 용.
412
+ * @returns {Array<{ name: string, cron: string, timezone: string|undefined, hasLock: boolean, nextRun: Date }>}
413
+ */
414
+ list() {
415
+ return [...this.#jobs.values()].map((j) => ({
416
+ name: j.name,
417
+ cron: j.cron,
418
+ timezone: j.timezone,
419
+ hasLock: j.lock !== undefined,
420
+ nextRun: MegaCron.next(j.cron, undefined, { timezone: j.timezone }),
421
+ }))
422
+ }
423
+
424
+ /** @returns {boolean} start() 된 상태면 true. */
425
+ get isStarted() {
426
+ return this.#started
427
+ }
428
+
429
+ /**
430
+ * 스케줄 1회 실행(락 중복방지 포함). 절대 throw 하지 않는다 — 결과/실패를 이벤트 + 반환 객체로 알린다
431
+ * (타이머 콜백에서 호출되므로 unhandled rejection 을 만들지 않기 위함).
432
+ * @param {string} name @returns {Promise<MegaScheduleFireResult>}
433
+ */
434
+ async #fire(name) {
435
+ const job = /** @type {ScheduleEntry} */ (this.#jobs.get(name))
436
+ const { instance, lock } = job
437
+ const ctx = this.#ctx
438
+
439
+ if (!lock) {
440
+ return this.#runBody(name, instance, ctx, undefined)
441
+ }
442
+
443
+ // 분산 중복방지 — lock 어댑터 해석.
444
+ if (!ctx || typeof ctx.lock !== 'function') {
445
+ // 설정 오류(lock 선언했는데 ctx.lock 없음)는 묵히지 않고 fail 로 표면화.
446
+ const error = new Error(
447
+ `MegaScheduler: schedule '${name}' declares static lock but ctx.lock(alias) is unavailable. Pass { ctx } with a lock accessor.`,
448
+ )
449
+ this.emit('fail', { name, error, phase: 'lock-setup' })
450
+ return { name, ran: false, skipped: false, ok: false, error }
451
+ }
452
+
453
+ // register 가 부팅 시 별명을 이미 검증하므로 정상 경로에선 throw 안 함. 그래도 #fire 의 불변식
454
+ // ("절대 throw 안 함")을 지키기 위해 방어적으로 감싼다 — 형제 케이스(ctx.lock 미주입)와 동일하게
455
+ // fail(lock-setup) 로 표면화한다(M-1: line 354 가 try/catch 밖이라 throw 가 새던 문제 해소).
456
+ let adapter
457
+ try {
458
+ adapter = ctx.lock(lock.lock)
459
+ } catch (e) {
460
+ const error = e instanceof Error ? e : new Error(String(e))
461
+ this.emit('fail', { name, error, phase: 'lock-setup' })
462
+ return { name, ran: false, skipped: false, ok: false, error }
463
+ }
464
+ const key = lock.key ?? `mega:schedule:${name}`
465
+
466
+ let held
467
+ try {
468
+ // 단 한 번 시도(retryCount: 0). 실패=이미 누가 보유(또는 backend 장애) → skip.
469
+ held = await adapter.acquire(key, { ttl: lock.ttl, retryCount: 0 })
470
+ } catch (e) {
471
+ const error = e instanceof Error ? e : new Error(String(e))
472
+ // 에러를 실어 관측 가능하게 — 인프라 장애도 여기로 오므로 소비자가 구분/경보(상단 docstring).
473
+ this.emit('skip', { name, key, reason: 'lock-not-acquired', error })
474
+ return { name, ran: false, skipped: true, ok: false, error }
475
+ }
476
+
477
+ try {
478
+ return await this.#runBody(name, instance, ctx, key)
479
+ } finally {
480
+ try {
481
+ await adapter.release(held)
482
+ } catch (e) {
483
+ // 락 해제 실패는 비치명적(ttl 로 자동 만료) — 다음 주기 재경쟁. 묵히지 않고 fail 로 표면화.
484
+ const error = e instanceof Error ? e : new Error(String(e))
485
+ this.emit('fail', { name, key, error, phase: 'release' })
486
+ }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * run(ctx) 실행 본문 — run/done/fail 이벤트 발화 + 결과 객체 구성. throw 하지 않는다.
492
+ * @param {string} name @param {MegaSchedule} instance @param {Record<string, any>|null} ctx
493
+ * @param {string|undefined} key @returns {Promise<MegaScheduleFireResult>}
494
+ */
495
+ async #runBody(name, instance, ctx, key) {
496
+ this.emit('run', { name, key })
497
+ try {
498
+ const result = await instance.run(ctx ?? {})
499
+ this.emit('done', { name, key })
500
+ return { name, ran: true, skipped: false, ok: true, result }
501
+ } catch (e) {
502
+ const error = e instanceof Error ? e : new Error(String(e))
503
+ this.emit('fail', { name, key, error, phase: 'run' })
504
+ return { name, ran: true, skipped: false, ok: false, error }
505
+ }
506
+ }
507
+ }