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,295 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaJobWorker — MegaJob 들을 NATS JetStream 에서 **소비**하는 잡 워커 런타임 (ADR-120).
4
+ *
5
+ * {@link MegaScheduler} 가 {@link import('./mega-schedule.js').MegaSchedule} 의 런타임이듯,
6
+ * `MegaJobWorker` 는 {@link import('./mega-job.js').MegaJob} 의 **소비 런타임**이다. 등록된 잡 클래스마다
7
+ * bus 별명을 풀어 {@link MegaJobQueue} 로 `consume()` 을 구동하고, 프로세스 graceful shutdown 시 모든
8
+ * 소비를 멈춘다(`stop()`).
9
+ *
10
+ * # ⚠️ 정본 `MegaWorker`(CPU worker_threads 풀)와 다른 추상 (ADR-120)
11
+ * 03-api-spec §6 의 `class MegaWorker` 는 **CPU-heavy 계산 격리용 worker_threads 풀**
12
+ * (`ctx.workers.<name>.run(task)`, 명시 호출)이다. 본 클래스는 그것과 **별개**로, NATS 잡을 소비하는
13
+ * 워커 프로세스 런타임이다(02-architecture §5-2 의 `mega worker` 명령이 "NATS subscribe → MegaJob 실행"
14
+ * 이라 한 그 역할). 두 개념을 같은 이름에 욱여넣지 않으려고 `MegaJobWorker` 로 분리했다(ADR-120).
15
+ *
16
+ * # concurrency 모델 — Promise concurrency (worker_threads 아님, ADR-120)
17
+ * 잡은 대부분 IO-bound(DB/HTTP/큐)이고, 동시 처리 상한은 이미 두 겹으로 걸린다: (1) NATS workqueue
18
+ * consumer 의 `max_ack_pending = static concurrency`(서버측 in-flight 제한), (2) `MegaJobQueue.consume`
19
+ * 의 인프로세스 Promise 동시성 상한. 따라서 별도 thread/process 풀 없이 **같은 이벤트루프의 Promise
20
+ * 동시성**으로 충분하다. CPU-bound 잡이 생기면 정본 `MegaWorker`(worker_threads) 후속 Step 으로 분리한다.
21
+ *
22
+ * # bus 배선 — 별명 → NatsConnection
23
+ * `MegaJobQueue` 는 연결된 `NatsConnection` 을 직접 받는다(별명 해석 안 함). 그 별명→nc
24
+ * 배선이 바로 본 런타임의 역할이다: `static bus` 별명을 `ctx.bus(alias).native` 로 풀어 nc 를 얻고, 같은
25
+ * bus 의 여러 잡은 **하나의 MegaJobQueue 인스턴스를 공유**한다(bus 별명당 1개).
26
+ *
27
+ * # logger 미주입, 이벤트로 노출 (형제 클래스와 동일)
28
+ * 순수 런타임이라 logger 를 모른다. 하부 {@link MegaJobQueue} 의 이벤트(`dispatch`/`start`/`done`/
29
+ * `retry`/`fail`/`dlq`)를 **그대로 재방출(forward)** 해, 소비자가 워커 1곳만 구독하면 모든 잡 큐의 길목을
30
+ * 관측할 수 있다. 타이머·콜백 경로라 `fail`/`dlq` 구독이 사실상 필수다.
31
+ *
32
+ * @module lib/mega-job-worker
33
+ * @see ADR-120, ADR-119 (MegaJob/MegaJobQueue), ADR-028 (3종 분리), ADR-117 (Step 분할)
34
+ */
35
+ import { EventEmitter } from 'node:events'
36
+ import { MegaConfigError } from '../errors/config-error.js'
37
+ import { MegaJob } from './mega-job.js'
38
+ import { MegaJobQueue } from './mega-job-queue.js'
39
+
40
+ /** 워커가 재방출하는 큐 이벤트 화이트리스트(MegaJobQueue 와 동일 — 오타 차단). */
41
+ const KNOWN_EVENTS = Object.freeze(['dispatch', 'start', 'done', 'retry', 'fail', 'dlq'])
42
+
43
+ /**
44
+ * @typedef {Object} JobEntry - 등록된 잡 1건의 내부 메타데이터.
45
+ * @property {string} name - 잡 클래스명(= 등록 키).
46
+ * @property {typeof MegaJob} JobClass - 등록된 잡 클래스.
47
+ * @property {MegaJob} instance - run 호출 대상 인스턴스.
48
+ * @property {string} subject - NATS subject.
49
+ * @property {string} busAlias - bus 별명(`ctx.bus(alias)`).
50
+ * @property {{ stop: () => Promise<void> }|null} handle - start() 가 만든 consume 핸들(미 start 면 null).
51
+ */
52
+
53
+ /**
54
+ * MegaJob 소비 워커 런타임. 잡 클래스를 등록하고 `start()` 로 소비를 시작, `stop()` 으로 graceful 중단한다.
55
+ *
56
+ * @example
57
+ * const worker = new MegaJobWorker({ ctx }) // ctx.bus(alias).native 로 nc 해석
58
+ * worker.on('dlq', (e) => log.error(e, 'job moved to DLQ'))
59
+ * worker.register(SendEmailJob).register(ResizeImageJob)
60
+ * await worker.start()
61
+ * MegaShutdown.register('worker', async () => { await worker.stop() }) // graceful shutdown 통합
62
+ */
63
+ export class MegaJobWorker extends EventEmitter {
64
+ /** @type {Record<string, any>} bus 별명 해석용 컨텍스트(+ run 에 전달). */ #ctx
65
+ /** @type {Partial<Omit<import('./mega-job-queue.js').MegaJobQueueOptions, 'nc'>>} 큐 옵션(nc 제외). */ #queueOptions
66
+ /** @type {Map<string, JobEntry>} 등록 잡(이름 → 메타 + consume 핸들). */ #jobs = new Map()
67
+ /** @type {Map<string, MegaJobQueue>} bus 별명 → 공유 MegaJobQueue 인스턴스. */ #queues = new Map()
68
+ /** @type {boolean} start() 후 stop() 전까지 true. */ #started = false
69
+
70
+ /**
71
+ * @param {object} args
72
+ * @param {Record<string, any>} args.ctx - 실행 컨텍스트. **반드시 `ctx.bus(alias)`(함수)** 가 있어야
73
+ * 하며(잡 bus 별명 해석), run(payload, ctx) 에도 그대로 전달된다.
74
+ * @param {Partial<Omit<import('./mega-job-queue.js').MegaJobQueueOptions, 'nc'>>} [args.queueOptions]
75
+ * - MegaJobQueue 옵션(`ackWaitMs`/`maxDeliver`/`heartbeatMs`/`streamPrefix`/`dlqMaxAgeMs`/`dlqMaxBytes`).
76
+ * `nc` 는 워커가 배선하므로 제외. DLQ 한도(`dlqMaxAgeMs` 디폴트 7일)는 그대로 큐로 전달된다(ADR-134).
77
+ * @throws {TypeError} ctx 또는 ctx.bus 가 없을 때(fail-fast — bus 해석 불가).
78
+ */
79
+ constructor({ ctx, queueOptions = {} } = /** @type {any} */ ({})) {
80
+ super()
81
+ if (!ctx || typeof ctx.bus !== 'function') {
82
+ throw new TypeError('MegaJobWorker({ ctx }) — ctx with a bus(alias) accessor is required (to resolve job bus → NatsConnection).')
83
+ }
84
+ this.#ctx = ctx
85
+ this.#queueOptions = queueOptions
86
+ }
87
+
88
+ // ── 이벤트 화이트리스트(형제 클래스와 동일 정책) ──────────────────────
89
+
90
+ /**
91
+ * 이벤트명 화이트리스트 검증. 모든 구독/해제 오버라이드가 공유.
92
+ * @param {string} method @param {string} event @returns {void}
93
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
94
+ */
95
+ #assertKnownEvent(method, event) {
96
+ if (!KNOWN_EVENTS.includes(event)) {
97
+ throw new RangeError(
98
+ `MegaJobWorker.${method}('${event}', ...) — unknown event. Known: ${KNOWN_EVENTS.join(', ')}.`,
99
+ )
100
+ }
101
+ }
102
+
103
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
104
+ on(event, listener) {
105
+ this.#assertKnownEvent('on', event)
106
+ return super.on(event, listener)
107
+ }
108
+
109
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
110
+ off(event, listener) {
111
+ this.#assertKnownEvent('off', event)
112
+ return super.off(event, listener)
113
+ }
114
+
115
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
116
+ once(event, listener) {
117
+ this.#assertKnownEvent('once', event)
118
+ return super.once(event, listener)
119
+ }
120
+
121
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
122
+ addListener(event, listener) {
123
+ this.#assertKnownEvent('addListener', event)
124
+ return super.addListener(event, listener)
125
+ }
126
+
127
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
128
+ removeListener(event, listener) {
129
+ this.#assertKnownEvent('removeListener', event)
130
+ return super.removeListener(event, listener)
131
+ }
132
+
133
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
134
+ prependListener(event, listener) {
135
+ this.#assertKnownEvent('prependListener', event)
136
+ return super.prependListener(event, listener)
137
+ }
138
+
139
+ /** @param {'dispatch'|'start'|'done'|'retry'|'fail'|'dlq'} event @param {(payload: any) => void} listener @returns {this} */
140
+ prependOnceListener(event, listener) {
141
+ this.#assertKnownEvent('prependOnceListener', event)
142
+ return super.prependOnceListener(event, listener)
143
+ }
144
+
145
+ // ── 등록 / 라이프사이클 ────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * 잡 클래스를 등록한다(아직 소비 안 함 — {@link MegaJobWorker#start} 가 consume 을 건다). subject·bus 를
149
+ * 즉시 검증해 잘못된 등록을 부팅 시점에 드러낸다(fail-fast — MegaScheduler.register 패턴).
150
+ *
151
+ * @param {typeof MegaJob} JobClass - `MegaJob` 를 상속한 클래스.
152
+ * @returns {this} 체이닝용.
153
+ * @throws {TypeError} MegaJob 서브클래스가 아니거나 subject 가 비었을 때.
154
+ * @throws {Error} 이름 중복일 때.
155
+ * @throws {MegaConfigError} `static bus` 가 비었거나 미선언 별명일 때.
156
+ */
157
+ register(JobClass) {
158
+ if (typeof JobClass !== 'function' || !(JobClass.prototype instanceof MegaJob)) {
159
+ throw new TypeError('MegaJobWorker.register(C) — C must be a subclass of MegaJob.')
160
+ }
161
+ const name = JobClass.name
162
+ if (this.#jobs.has(name)) {
163
+ throw new Error(`MegaJobWorker.register: job '${name}' is already registered.`)
164
+ }
165
+ // subject 검증은 MegaJobQueue 가 enqueue/consume 시 #assertJobSubject 로 하지만, 등록 시점에 한 번 더
166
+ // 당겨 부팅 fail-fast. 비어있지 않은 문자열만 여기서 보고, 형식 상세는 큐가 책임진다.
167
+ const subject = JobClass.subject
168
+ if (typeof subject !== 'string' || subject.trim() === '') {
169
+ throw new TypeError(`MegaJobWorker.register('${name}') — static subject must be a non-empty string.`)
170
+ }
171
+ // bus 별명 — 미지정 시 잡 nc 를 풀 수 없으므로 fail-fast. 별명이 ctx 에 선언됐는지도 즉시 검증.
172
+ const busAlias = JobClass.bus
173
+ if (typeof busAlias !== 'string' || busAlias.trim() === '') {
174
+ throw new MegaConfigError(
175
+ 'job.missing_bus',
176
+ `MegaJobWorker.register('${name}') — static bus must be a non-empty string (the ctx.bus(alias) name).`,
177
+ { details: { job: name } },
178
+ )
179
+ }
180
+ this.#assertBusAliasDeclared(name, busAlias)
181
+
182
+ const instance = new JobClass()
183
+ this.#jobs.set(name, { name, JobClass, instance, subject, busAlias, handle: null })
184
+ return this
185
+ }
186
+
187
+ /**
188
+ * bus 별명이 ctx 에 선언돼 있는지 부팅 시점에 검증한다(fail-fast — MegaScheduler.#assertLockAliasDeclared
189
+ * 패턴). `ctx.bus(alias)` 를 1회 호출해 미선언 별명이면 즉시 throw — 오타가 첫 소비 시점까지 숨지 않게.
190
+ * @param {string} name @param {string} alias @returns {void}
191
+ * @throws {MegaConfigError} 별명이 미선언일 때.
192
+ */
193
+ #assertBusAliasDeclared(name, alias) {
194
+ try {
195
+ this.#ctx.bus(alias) // 미선언 별명이면 ctx-builder 가 MegaConfigError throw — 부팅에서 잡는다.
196
+ } catch (e) {
197
+ const cause = e instanceof Error ? e : new Error(String(e))
198
+ throw new MegaConfigError(
199
+ 'job.unknown_bus_alias',
200
+ `MegaJobWorker.register('${name}') — bus alias '${alias}' is not a declared bus adapter ` +
201
+ `(ctx.bus('${alias}') failed: ${cause.message}).`,
202
+ { details: { job: name, alias }, cause },
203
+ )
204
+ }
205
+ }
206
+
207
+ /**
208
+ * bus 별명에 대한 공유 {@link MegaJobQueue} 를 멱등 생성한다 — 같은 bus 의 여러 잡이 한 nc·한 큐를 공유.
209
+ * 큐 이벤트를 워커로 재방출(forward)해 소비자가 워커 1곳만 구독하면 되게 한다.
210
+ * @param {string} busAlias @returns {MegaJobQueue}
211
+ */
212
+ #queueFor(busAlias) {
213
+ const existing = this.#queues.get(busAlias)
214
+ if (existing) return existing
215
+ const adapter = this.#ctx.bus(busAlias) // register 에서 이미 검증 — 정상 경로에선 throw 안 함.
216
+ const nc = adapter.native // MegaBusAdapter.native = 연결된 NatsConnection(ADR-009).
217
+ const queue = new MegaJobQueue({ nc, ...this.#queueOptions })
218
+ // 큐 길목 이벤트를 워커로 그대로 재방출 — 소비자는 worker.on(...) 한 곳만 구독.
219
+ for (const event of KNOWN_EVENTS) {
220
+ queue.on(/** @type {any} */ (event), (payload) => this.emit(event, payload))
221
+ }
222
+ this.#queues.set(busAlias, queue)
223
+ return queue
224
+ }
225
+
226
+ /**
227
+ * 등록된 모든 잡의 소비를 시작한다(각 잡의 bus 큐로 `consume`). 이미 start 된 잡은 건너뛴다. 멱등.
228
+ * @returns {Promise<this>}
229
+ */
230
+ async start() {
231
+ for (const job of this.#jobs.values()) {
232
+ if (job.handle !== null) continue // 이미 소비 중.
233
+ const queue = this.#queueFor(job.busAlias)
234
+ job.handle = await queue.consume(job.JobClass, job.instance, this.#ctx)
235
+ }
236
+ this.#started = true
237
+ return this
238
+ }
239
+
240
+ /**
241
+ * 모든 잡 소비를 graceful 중단한다(consume 핸들 stop — 진행 중 처리는 마무리까지 대기). graceful
242
+ * shutdown 필수(`MegaShutdown.register('worker', () => worker.stop())`). 한 잡의 stop 실패가 나머지
243
+ * 정리를 막지 않도록 allSettled 로 모은다(방어). 단, 하부 `MegaJobQueue` 의 consume 핸들 `stop()` 은
244
+ * `ConsumerMessages.close()` 가 reject 하지 않는 설계라(nats 2.29: drain 오류를 `Promise<void|Error>` 로
245
+ * resolve) close 에러를 **큐 이벤트가 아니라 console(stderr)로 표면화**한다(L-3). 즉 allSettled 의
246
+ * rejected 슬롯은 정상 경로에선 비고, 방어적 안전망으로만 둔다.
247
+ * @returns {Promise<this>}
248
+ */
249
+ async stop() {
250
+ const stops = []
251
+ for (const job of this.#jobs.values()) {
252
+ if (job.handle === null) continue
253
+ stops.push(job.handle.stop())
254
+ job.handle = null
255
+ }
256
+ await Promise.allSettled(stops)
257
+ this.#started = false
258
+ return this
259
+ }
260
+
261
+ /**
262
+ * 잡을 큐에 넣는다(편의 위임 — producer 측). 워커가 해당 bus 큐를 통해 enqueue 한다.
263
+ * @param {typeof MegaJob} JobClass @param {any} payload
264
+ * @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
265
+ */
266
+ async enqueue(JobClass, payload) {
267
+ const busAlias = JobClass.bus
268
+ if (typeof busAlias !== 'string' || busAlias.trim() === '') {
269
+ throw new MegaConfigError(
270
+ 'job.missing_bus',
271
+ `MegaJobWorker.enqueue('${JobClass?.name}') — static bus must be a non-empty string.`,
272
+ { details: { job: JobClass?.name } },
273
+ )
274
+ }
275
+ return this.#queueFor(busAlias).enqueue(JobClass, payload)
276
+ }
277
+
278
+ /**
279
+ * 등록된 잡 메타데이터 목록. 모니터링/CLI 용.
280
+ * @returns {Array<{ name: string, subject: string, bus: string, consuming: boolean }>}
281
+ */
282
+ list() {
283
+ return [...this.#jobs.values()].map((j) => ({
284
+ name: j.name,
285
+ subject: j.subject,
286
+ bus: j.busAlias,
287
+ consuming: j.handle !== null,
288
+ }))
289
+ }
290
+
291
+ /** @returns {boolean} start() 된 상태면 true. */
292
+ get isStarted() {
293
+ return this.#started
294
+ }
295
+ }
@@ -0,0 +1,140 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaJob — NATS JetStream 기반 영속 잡 베이스 클래스 (ADR-119).
4
+ *
5
+ * 03-api-spec §6 의 "클래스 + static 설정" 패턴(MegaSchedule/MegaWorker 와 동형). 서브클래스가
6
+ * static 설정과 `async run(payload, ctx)` 를 정의하면, 실행 런타임 {@link MegaJobQueue}(JetStream
7
+ * wrapper)가 enqueue/consume·재시도·DLQ 를 담당한다.
8
+ *
9
+ * # 왜 JetStream 인가 (중학생용)
10
+ * 보통 메시지(core NATS)는 받을 사람이 그 순간 없으면 사라진다(at-most-once). 잡은 "꼭 한 번은
11
+ * 처리돼야" 하므로, 서버에 **저장(persist)** 해두고 워커가 가져가 처리한 뒤 "처리 완료(ack)" 를
12
+ * 보내야 지워지는 큐가 필요하다 — 그게 JetStream 의 **workqueue** 스트림이다. 여러 워커가 같은 큐를
13
+ * 봐도 메시지 1건은 **딱 한 워커**에게만 간다(분산 중복방지 = OQ-012 의 leader election 을 큐가 대신).
14
+ *
15
+ * # 재시도 → DLQ (MegaRetry 재사용)
16
+ * `run()` 이 실패하면 {@link MegaJobQueue} 가 `MegaRetry`(p-retry)로 `static retries`·
17
+ * `static backoff` 설정만큼 **인프로세스 지수 백오프 재시도**한다. 그래도 모두 실패하면 메시지를
18
+ * **DLQ(Dead Letter Queue) subject `<subject>.dlq`** 로 보내 따로 보관한다(원인 분석·재처리용).
19
+ *
20
+ * # 설정 필드 (03-api-spec §6 정본)
21
+ * - `static subject` : 잡 NATS subject(필수). 워크 스트림이 이 subject 를 저장한다.
22
+ * - `static bus` : bus 별명(`ctx.bus(alias)` → MegaNatsAdapter). 워커 배선이 사용.
23
+ * - `static concurrency` : 동시에 처리할 메시지 수(consumer `max_ack_pending`). 기본 1(순차·안전).
24
+ * - `static retries` : run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3.
25
+ * - `static backoff` : `{ type:'exponential', initial, max }`. p-retry 로 매핑(factor=2, jitter=on).
26
+ *
27
+ * @module lib/mega-job
28
+ * @see ADR-119, ADR-028 (잡·스케줄러·워커 3종 분리), ADR-029 (라이브러리 래핑)
29
+ */
30
+
31
+ /**
32
+ * 잡 백오프 설정. 03-api-spec §6 의 `static backoff` 형식. `type` 은 현재 `'exponential'` 만 지원한다
33
+ * (미지원 type 은 {@link resolveJobRetryConfig} 에서 throw — 추측 진행 금지).
34
+ *
35
+ * @typedef {Object} MegaJobBackoff
36
+ * @property {string} type - 백오프 종류. **현재 `'exponential'` 만 지원**(미지원 type 은
37
+ * {@link resolveJobRetryConfig} 가 런타임 throw — 타입은 서브클래스 리터럴 오버라이드 편의를 위해
38
+ * 넓게 두고 검증은 런타임이 담당).
39
+ * @property {number} initial - 첫 재시도 지연(ms) = p-retry `minTimeout`.
40
+ * @property {number} max - 재시도 지연 상한(ms) = p-retry `maxTimeout`.
41
+ */
42
+
43
+ /**
44
+ * 잡 큐 실패·재시도·DLQ 기본값(OQ-012 결정, ADR-119). p-retry(MegaRetry) 정합 디폴트:
45
+ * 추가 재시도 3회, 지수 factor 2, 첫 지연 1s, 상한 30s, jitter on(thundering herd 완화).
46
+ * @type {{ retries: 3, backoff: MegaJobBackoff }}
47
+ */
48
+ export const JOB_RETRY_DEFAULTS = Object.freeze({
49
+ retries: 3,
50
+ backoff: Object.freeze({ type: 'exponential', initial: 1000, max: 30_000 }),
51
+ })
52
+
53
+ /**
54
+ * NATS JetStream 기반 영속 잡 베이스 클래스. 서브클래스가 static 설정과 `run(payload, ctx)` 를 정의한다.
55
+ *
56
+ * @example
57
+ * export class SendEmailJob extends MegaJob {
58
+ * static subject = 'send-email'
59
+ * static bus = 'jobs'
60
+ * static concurrency = 5
61
+ * static retries = 3
62
+ * static backoff = { type: 'exponential', initial: 1000, max: 30_000 }
63
+ * async run(payload, ctx) {
64
+ * await ctx.cache('main').set(`sent:${payload.id}`, '1')
65
+ * }
66
+ * }
67
+ */
68
+ export class MegaJob {
69
+ /** @type {string|undefined} 잡 NATS subject(필수 — 미정의 시 큐 등록에서 throw). */
70
+ static subject = undefined
71
+
72
+ /** @type {string|undefined} bus 별명(`ctx.bus(alias)`). 워커 배선이 nc 해석에 사용. */
73
+ static bus = undefined
74
+
75
+ /** @type {number} 동시 처리 메시지 수(consumer max_ack_pending). 기본 1(순차·안전). */
76
+ static concurrency = 1
77
+
78
+ /** @type {number} run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3(OQ-012). */
79
+ static retries = JOB_RETRY_DEFAULTS.retries
80
+
81
+ /** @type {MegaJobBackoff} 지수 백오프 설정. 기본 { exponential, 1s, 30s }(OQ-012). */
82
+ static backoff = JOB_RETRY_DEFAULTS.backoff
83
+
84
+ /**
85
+ * 메시지 1건마다 실행되는 본문. 서브클래스가 **반드시** 구현한다. throw 하면 {@link MegaJobQueue}
86
+ * 가 재시도하고, 재시도 소진 시 DLQ 로 보낸다 — 그러므로 비치명/일시 오류는 그냥 throw 하면 된다.
87
+ * @param {any} _payload - enqueue 된 잡 페이로드(JSON 디코드된 값).
88
+ * @param {Record<string, any>} _ctx - 실행 컨텍스트(`db/cache/bus/lock` 접근자 등).
89
+ * @returns {Promise<any>}
90
+ * @throws {Error} 서브클래스가 구현하지 않으면.
91
+ */
92
+ async run(_payload, _ctx) {
93
+ throw new Error(
94
+ `${this.constructor.name}: MegaJob subclass must implement async run(payload, ctx).`,
95
+ )
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 잡 클래스의 `static retries`/`static backoff` 를 {@link import('./mega-retry.js').MegaRetryOptions}
101
+ * 형식으로 매핑한다(03-api-spec 필드 → p-retry 옵션). 미지정 필드는 OQ-012 디폴트로 채운다.
102
+ *
103
+ * @param {typeof MegaJob} JobClass - 잡 클래스.
104
+ * @returns {{ retries: number, minTimeout: number, maxTimeout: number, factor: number, jitter: boolean }}
105
+ * @throws {TypeError} retries 가 음수/비정수이거나 backoff 형식이 무효일 때(fail-fast).
106
+ */
107
+ export function resolveJobRetryConfig(JobClass) {
108
+ const retries = JobClass.retries ?? JOB_RETRY_DEFAULTS.retries
109
+ if (typeof retries !== 'number' || !Number.isInteger(retries) || retries < 0) {
110
+ throw new TypeError(
111
+ `MegaJob '${JobClass.name}': static retries must be a non-negative integer. Got: ${retries}.`,
112
+ )
113
+ }
114
+ // null/undefined 는 디폴트로 대체(?? ) — 이후 backoff 는 항상 값이 있다. 비-객체(문자열·숫자 등)만 거른다.
115
+ const backoff = JobClass.backoff ?? JOB_RETRY_DEFAULTS.backoff
116
+ if (typeof backoff !== 'object') {
117
+ throw new TypeError(
118
+ `MegaJob '${JobClass.name}': static backoff must be an object { type, initial, max }.`,
119
+ )
120
+ }
121
+ // 현재 exponential 만 지원 — 미지원 type 을 조용히 다른 백오프로 해석하지 않는다.
122
+ if (backoff.type !== 'exponential') {
123
+ throw new TypeError(
124
+ `MegaJob '${JobClass.name}': static backoff.type '${backoff.type}' is not supported (only 'exponential').`,
125
+ )
126
+ }
127
+ const { initial, max } = backoff
128
+ if (typeof initial !== 'number' || initial <= 0) {
129
+ throw new TypeError(
130
+ `MegaJob '${JobClass.name}': static backoff.initial must be a positive number (ms). Got: ${initial}.`,
131
+ )
132
+ }
133
+ if (typeof max !== 'number' || max < initial) {
134
+ throw new TypeError(
135
+ `MegaJob '${JobClass.name}': static backoff.max must be a number >= initial (${initial}). Got: ${max}.`,
136
+ )
137
+ }
138
+ // factor=2(exponential), jitter=on — OQ-012 정합. MegaRetry 가 첫 시도 즉시 + 이후 지수 백오프.
139
+ return { retries, minTimeout: initial, maxTimeout: max, factor: 2, jitter: true }
140
+ }
@@ -0,0 +1,128 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaLogger — pino + multi-sink 통합 (ADR-023/141).
4
+ *
5
+ * # 무엇인가 (중학생용 설명)
6
+ * 서버가 무슨 일을 했는지 기록(로그)을 남기는 장치다. 검증된 라이브러리 `pino`(아주 빠른 JSON 로거) 위에,
7
+ * `mega.config.js` 의 `logger` 설정대로 **여러 출력처(sink)** 를 동시에 연결한다:
8
+ * - console (dev 는 보기 좋은 pretty, prod 는 JSON 한 줄)
9
+ * - file (날짜별 로테이션 + 오래된 파일 N개만 보관)
10
+ * - telegram (warn 이상만, worker thread 로 비동기 전송 + 실패 시 재시도 — 메인 루프 안 막음)
11
+ *
12
+ * # 핵심 (ADR-023)
13
+ * - **request_id 주입**: Fastify 가 `req.log` 에 `reqId` 를 자동 바인딩한다(요청별 상관).
14
+ * - **trace_id 주입**: pino `mixin` = `MegaTracing.logMixin` → 활성 span 의 `trace_id`/`span_id` 자동 첨부(ADR-116).
15
+ * - **시크릿 redact**: pino `redact` 로 `*.password`/`*.token`/`authorization` 등 마스킹(메인 스레드에서 적용 →
16
+ * transport 가 받는 NDJSON 엔 이미 시크릿 없음).
17
+ * - **worker thread 격리**: pino transport 는 worker thread 에서 돈다 — 무거운 IO(파일·텔레그램)가 이벤트루프 보호.
18
+ *
19
+ * # sink 확장 (ADR-027)
20
+ * 새 sink 는 `MegaLogSinkAdapter`(추상, src/adapters) 계약을 따르는 pino transport 모듈을 추가하면 된다
21
+ * (텔레그램 sink 가 그 예 — `logger/telegram-transport.js`).
22
+ *
23
+ * @module lib/mega-logger
24
+ * @see ADR-023
25
+ * @see ADR-141
26
+ */
27
+ import { fileURLToPath } from 'node:url'
28
+ import pino from 'pino'
29
+ import * as MegaTracing from './mega-tracing.js'
30
+
31
+ /** 텔레그램 transport 모듈 절대경로(pino worker 가 path 로 로드). */
32
+ const TELEGRAM_TRANSPORT = fileURLToPath(new URL('./logger/telegram-transport.js', import.meta.url))
33
+
34
+ /** sink 디폴트 — 텔레그램은 warn 이상(ADR-023). */
35
+ export const TELEGRAM_DEFAULT_LEVEL = 'warn'
36
+
37
+ /**
38
+ * `logger` config 의 sink 배열을 pino transport target 배열로 변환한다(순수 — 테스트 가능).
39
+ *
40
+ * @param {Array<Record<string, any>>} sinks - `[{ type:'console'|'file'|'telegram', ... }]`.
41
+ * @param {string} level - 전역 레벨(sink 가 자기 level 미지정 시 사용).
42
+ * @returns {Array<{ target: string, level: string, options: Record<string, any> }>}
43
+ */
44
+ export function buildTargets(sinks, level) {
45
+ /** @type {Array<{ target: string, level: string, options: Record<string, any> }>} */
46
+ const targets = []
47
+ for (const sink of sinks) {
48
+ if (!sink || typeof sink !== 'object') continue
49
+ const sinkLevel = typeof sink.level === 'string' ? sink.level : level
50
+ if (sink.type === 'console') {
51
+ if (sink.pretty) {
52
+ // dev pretty — pino-pretty 가 NDJSON 을 사람이 읽기 좋게. 시크릿은 이미 redact 됨.
53
+ targets.push({
54
+ target: 'pino-pretty',
55
+ level: sinkLevel,
56
+ options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' },
57
+ })
58
+ } else {
59
+ // prod — stdout 으로 JSON 한 줄(destination 1 = stdout).
60
+ targets.push({ target: 'pino/file', level: sinkLevel, options: { destination: 1 } })
61
+ }
62
+ } else if (sink.type === 'file') {
63
+ // 날짜별 로테이션 + keep N — pino-roll(pino 팀 공식). frequency 'daily', limit.count = keep.
64
+ targets.push({
65
+ target: 'pino-roll',
66
+ level: sinkLevel,
67
+ options: {
68
+ file: sink.path,
69
+ frequency: sink.rotation === 'daily' || sink.rotation === undefined ? 'daily' : sink.rotation,
70
+ mkdir: true,
71
+ ...(Number.isInteger(sink.keep) && sink.keep > 0 ? { limit: { count: sink.keep } } : {}),
72
+ },
73
+ })
74
+ } else if (sink.type === 'telegram') {
75
+ // warn 이상만(디폴트) — worker thread + throttle + disk retry(telegram-transport.js).
76
+ targets.push({
77
+ target: TELEGRAM_TRANSPORT,
78
+ level: typeof sink.level === 'string' ? sink.level : TELEGRAM_DEFAULT_LEVEL,
79
+ options: {
80
+ botToken: sink.botToken,
81
+ chatId: sink.chatId,
82
+ ...(Number.isInteger(sink.throttleMax) ? { throttleMax: sink.throttleMax } : {}),
83
+ ...(Number.isInteger(sink.throttleWindowMs) ? { throttleWindowMs: sink.throttleWindowMs } : {}),
84
+ ...(typeof sink.retryDir === 'string' ? { retryDir: sink.retryDir } : {}),
85
+ ...(typeof sink.serviceName === 'string' ? { serviceName: sink.serviceName } : {}),
86
+ },
87
+ })
88
+ }
89
+ // 알 수 없는 type 은 조용히 무시하지 않고 — config-validator 가 부팅 시 막는 게 정석이나, 여기선
90
+ // 빌더라 미지원 type 을 건너뛴다(검증은 별도). 빈 결과면 buildLoggerOptions 가 null 반환.
91
+ }
92
+ return targets
93
+ }
94
+
95
+ /**
96
+ * `logger` config → pino 옵션(또는 비활성 시 `null`). Fastify `logger` 또는 `pino()` 에 그대로 전달 가능.
97
+ *
98
+ * @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact?, includeRequestId? }`).
99
+ * @returns {{ level: string, mixin: Function, redact?: string[], transport: { targets: any[] } } | null}
100
+ */
101
+ export function buildLoggerOptions(config) {
102
+ if (!config || typeof config !== 'object') return null
103
+ const c = /** @type {Record<string, any>} */ (config)
104
+ if (!Array.isArray(c.sinks) || c.sinks.length === 0) return null
105
+ const level = typeof c.level === 'string' ? c.level : 'info'
106
+ const targets = buildTargets(c.sinks, level)
107
+ if (targets.length === 0) return null
108
+ return {
109
+ level,
110
+ // trace_id/span_id 자동 주입(ADR-116) — 활성 span 없으면 빈 객체(0 비용).
111
+ mixin: MegaTracing.logMixin,
112
+ ...(Array.isArray(c.redact) && c.redact.length > 0 ? { redact: c.redact } : {}),
113
+ transport: { targets },
114
+ }
115
+ }
116
+
117
+ /**
118
+ * `logger` config 로 pino 인스턴스를 만든다(비활성이면 `null`). bootApp 이 한 번 만들어 모든 앱이 공유한다
119
+ * (worker thread·파일 핸들 1벌). Fastify 는 인스턴스를 받으면 요청별 child(reqId 바인딩)를 만든다.
120
+ *
121
+ * @param {unknown} config - `MegaLoggerConfig`.
122
+ * @returns {import('pino').Logger | null}
123
+ */
124
+ export function buildLogger(config) {
125
+ const options = buildLoggerOptions(config)
126
+ if (options === null) return null
127
+ return pino(/** @type {any} */ (options))
128
+ }