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,232 @@
1
+ // @ts-check
2
+ import cluster from 'node:cluster'
3
+ import os from 'node:os'
4
+
5
+ /**
6
+ * MegaCluster — Node cluster 모듈 래퍼.
7
+ *
8
+ * 사용:
9
+ * const cluster = new MegaCluster({ instances: 'max' })
10
+ * await cluster.start(async () => {
11
+ * // 워커 프로세스 안에서 실행되는 코드 — MegaApp/Server 부팅
12
+ * await server.listen({ port: 3000 })
13
+ * })
14
+ *
15
+ * 마스터 프로세스: 워커 N개 fork + IPC + SIGTERM/SIGINT → 워커들에 'shutdown'.
16
+ * 워커 프로세스: 사용자 함수 실행. 마스터로부터 'shutdown' 받으면 graceful close.
17
+ *
18
+ * ADR-030 (cluster 직접). graceful shutdown 시퀀스 강화는 MegaShutdown 영역.
19
+ */
20
+
21
+ const SHUTDOWN_MSG = 'mega-shutdown'
22
+
23
+ /**
24
+ * 코어가 워커에 부착하는 비표준 프로퍼티(`_megaBirthAt`, 수명 측정용)를 포함한 Worker.
25
+ * @typedef {import('node:cluster').Worker & { _megaBirthAt?: number }} MegaWorker
26
+ */
27
+ // NOTE: 워커 ready 협응(부팅 완료 신호)은 현재 미사용. 필요해지면 후속 Phase 에서
28
+ // 마스터 수신부까지 양방향(send + receive)으로 한 번에 배선한다.
29
+
30
+ export class MegaCluster {
31
+ /**
32
+ * @param {Object} [opts]
33
+ * @param {number | 'max'} [opts.instances] - 워커 수. 'max' 면 CPU 코어 수.
34
+ * @param {boolean} [opts.respawn=true] - 워커 crash 시 자동 재시작
35
+ * @param {number} [opts.gracePeriodMs=30000] - SIGTERM 후 강제 kill 까지 대기
36
+ * @param {import('node:cluster').Cluster} [opts._cluster] - 테스트용 cluster 주입(기본 node:cluster). per ADR-165
37
+ * @param {NodeJS.Process} [opts._proc] - 테스트용 process 주입(기본 전역 process). per ADR-165
38
+ */
39
+ constructor(opts = {}) {
40
+ this._instances = resolveInstances(opts.instances)
41
+ this._respawn = opts.respawn !== false
42
+ this._gracePeriodMs = opts.gracePeriodMs ?? 30_000
43
+ this._shuttingDown = false
44
+ // fork/IPC/시그널 경로는 자식 프로세스 안에서만 실행돼 부모 v8 커버리지로 측정 불가다.
45
+ // cluster/process 를 주입 seam 으로 분리해 fake 로 in-process 단위 검증한다. per ADR-165
46
+ this._cluster = opts._cluster ?? cluster
47
+ this._proc = opts._proc ?? process
48
+ /** @type {Set<MegaWorker>} */
49
+ this._workers = new Set()
50
+ /** @type {(() => Promise<void>) | null} */
51
+ this._workerFn = null
52
+
53
+ // M1 — respawn backoff (crash-loop 보호)
54
+ this._respawnTooFast = 0 // 빠른 연속 crash 카운터
55
+ this._maxRapidRespawn = 5 // 5번 연속 너무 빨리 crash 하면 중단
56
+ this._minRespawnIntervalMs = 1000 // 1초 이내 crash 는 "너무 빠름"
57
+ }
58
+
59
+ /**
60
+ * 마스터 / 워커 둘 다 호출. 마스터면 fork, 워커면 사용자 함수 실행.
61
+ * @param {() => Promise<void>} workerFn - 워커 프로세스에서 실행할 부팅 함수.
62
+ * @returns {Promise<void>}
63
+ */
64
+ async start(workerFn) {
65
+ if (typeof workerFn !== 'function') {
66
+ throw new Error('MegaCluster.start: workerFn must be a function')
67
+ }
68
+ if (this._cluster.isPrimary) {
69
+ return this._startPrimary(workerFn)
70
+ }
71
+ return this._startWorker(workerFn)
72
+ }
73
+
74
+ /** boolean 네이밍 (ADR-036). */
75
+ isShuttingDown() {
76
+ return this._shuttingDown
77
+ }
78
+
79
+ /** boolean — primary 프로세스인지. */
80
+ isPrimary() {
81
+ return this._cluster.isPrimary
82
+ }
83
+
84
+ /** boolean — worker 프로세스인지. */
85
+ isWorker() {
86
+ return !this._cluster.isPrimary
87
+ }
88
+
89
+ /**
90
+ * @private 마스터 프로세스 부팅 시퀀스.
91
+ * @param {() => Promise<void>} workerFn
92
+ */
93
+ async _startPrimary(workerFn) {
94
+ this._workerFn = workerFn
95
+ // workerFn 은 마스터에서 사용 안 함 (정보 전달용 placeholder)
96
+ for (let i = 0; i < this._instances; i++) {
97
+ this._forkWorker()
98
+ }
99
+
100
+ // SIGTERM/SIGINT → graceful shutdown 협응
101
+ const onSignal = (/** @type {string} */ sig) => {
102
+ if (this._shuttingDown) return
103
+ this._shuttingDown = true
104
+ console.log(`[mega-cluster] received ${sig}, broadcasting shutdown to ${this._workers.size} workers (grace ${this._gracePeriodMs}ms)`)
105
+ this._broadcastShutdown()
106
+ // grace 초과 시 강제 kill
107
+ setTimeout(() => {
108
+ for (const w of this._workers) {
109
+ try { w.kill('SIGKILL') } catch (err) { console.warn('[mega-cluster] SIGKILL failed:', err.message) }
110
+ }
111
+ this._proc.exit(1)
112
+ }, this._gracePeriodMs).unref()
113
+ }
114
+ this._proc.on('SIGTERM', () => onSignal('SIGTERM'))
115
+ this._proc.on('SIGINT', () => onSignal('SIGINT'))
116
+ }
117
+
118
+ /** @private 워커 fork + 라이프사이클 wiring. */
119
+ _forkWorker() {
120
+ const worker = /** @type {MegaWorker} */ (this._cluster.fork())
121
+ worker._megaBirthAt = Date.now() // 수명 측정용 (M1 respawn backoff)
122
+ this._workers.add(worker)
123
+ worker.on('exit', (code, signal) => {
124
+ this._workers.delete(worker)
125
+ if (this._shuttingDown) {
126
+ if (this._workers.size === 0) {
127
+ console.log('[mega-cluster] all workers exited, primary exiting 0')
128
+ this._proc.exit(0)
129
+ }
130
+ return
131
+ }
132
+ if (this._respawn) {
133
+ // M1 — respawn backoff: 너무 빨리 연속 crash 하면 crash-loop 으로 보고 respawn 중단.
134
+ const now = Date.now()
135
+ const lastBirth = worker._megaBirthAt ?? now
136
+ const lifetimeMs = now - lastBirth
137
+ if (lifetimeMs < this._minRespawnIntervalMs) {
138
+ this._respawnTooFast += 1
139
+ if (this._respawnTooFast >= this._maxRapidRespawn) {
140
+ // H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로
141
+ // 보인다 → systemd/k8s 가 "정상 종료" 로 판단해 재시작 안 함.
142
+ // 명시적 exit 1 로 "비정상 종료" 를 외부 supervisor 에 알려 재시작을 유도한다.
143
+ console.error(
144
+ `[mega-cluster] rapid crash-loop detected (${this._respawnTooFast} restarts within ${this._minRespawnIntervalMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
145
+ )
146
+ this._proc.exit(1)
147
+ return // 실 process.exit 은 즉시 종료하나, 주입된 fake 는 계속 실행되므로 명시적 중단
148
+ }
149
+ } else {
150
+ this._respawnTooFast = 0 // 정상 lifetime 이면 카운터 reset
151
+ }
152
+ console.warn(
153
+ `[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crash-count=${this._respawnTooFast}/${this._maxRapidRespawn})`,
154
+ )
155
+ this._forkWorker()
156
+ } else {
157
+ console.warn(`[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}), respawn disabled`)
158
+ }
159
+ })
160
+ }
161
+
162
+ /** @private 모든 워커에 shutdown 메시지 송신. */
163
+ _broadcastShutdown() {
164
+ for (const w of this._workers) {
165
+ try {
166
+ w.send({ type: SHUTDOWN_MSG })
167
+ } catch (err) {
168
+ // 워커가 이미 disconnected — 무시 + 로그 (silent 금지)
169
+ console.warn(`[mega-cluster] failed to send shutdown to worker ${w.process.pid}:`, err.message)
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * @private 워커 프로세스 시작 — 사용자 함수 실행 + 마스터 메시지 수신.
176
+ * @param {() => Promise<void>} workerFn
177
+ */
178
+ async _startWorker(workerFn) {
179
+ // NOTE: 워커 graceful 종료는 사용자 SIGTERM 핸들러(또는 MegaShutdown.setupSignals)에 위임.
180
+ // 무핸들러 감지 + warn 로그 + respawn backoff(crash-loop 보호).
181
+ // 워커 ready 협응 양방향 배선은 후속 Phase (필요 시) 에서 박을 예정.
182
+ // 마스터로부터 shutdown 메시지 수신 → 워커 graceful close
183
+ this._proc.on('message', (msg) => {
184
+ // IPC 메시지는 Serializable 타입 — shutdown 협응 형태로 좁혀 읽는다.
185
+ const data = /** @type {{ type?: string }} */ (msg)
186
+ if (data?.type === SHUTDOWN_MSG && !this._shuttingDown) {
187
+ this._shuttingDown = true
188
+ // M2 — process.emit 의 반환값으로 SIGTERM 핸들러 등록 여부 감지.
189
+ // 핸들러가 있었으면 true, 없으면 false → 무핸들러 경고.
190
+ const hadListener = this._proc.emit('SIGTERM')
191
+ if (!hadListener) {
192
+ console.warn(
193
+ `[mega-cluster] worker ${this._proc.pid} has no SIGTERM handler registered. Will be force-killed by master after grace period. ` +
194
+ `Register a SIGTERM handler (or use MegaShutdown.setupSignals) to enable graceful shutdown.`,
195
+ )
196
+ }
197
+ }
198
+ })
199
+
200
+ // 사용자 함수 실행 (MegaApp/Server 부팅)
201
+ try {
202
+ await workerFn()
203
+ } catch (err) {
204
+ console.error(`[mega-cluster] worker ${this._proc.pid} workerFn failed:`, err)
205
+ this._proc.exit(1)
206
+ }
207
+ }
208
+
209
+ /** 외부에서 강제 종료 트리거 (테스트용). */
210
+ async shutdown() {
211
+ if (this._shuttingDown) return
212
+ this._shuttingDown = true
213
+ if (this._cluster.isPrimary) {
214
+ this._broadcastShutdown()
215
+ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * @private
221
+ * @param {number | 'max' | undefined} input
222
+ * @returns {number}
223
+ */
224
+ function resolveInstances(input) {
225
+ if (input === 'max' || input === undefined) {
226
+ return Math.max(1, os.availableParallelism?.() ?? os.cpus().length)
227
+ }
228
+ if (typeof input === 'number' && Number.isFinite(input) && input >= 1) {
229
+ return Math.floor(input)
230
+ }
231
+ throw new Error(`MegaCluster: instances must be a positive number or 'max'. Got: ${input}`)
232
+ }
@@ -0,0 +1,176 @@
1
+ // @ts-check
2
+ import { createServer as createHttpServer } from 'node:http'
3
+ import { MegaConfigError } from '../errors/config-error.js'
4
+
5
+ /**
6
+ * MegaServer — 여러 MegaApp 을 하나의 HTTP 서버에서 호스트네임 기반으로 라우팅.
7
+ *
8
+ * 단일 HTTP 서버 + Host 헤더 분기. ADR-003 (도메인 기반 멀티앱).
9
+ * WS hub·cluster master 의 토대.
10
+ *
11
+ * 동작:
12
+ * 1. mount(app) 으로 MegaApp 등록 (hosts 배열의 각 host 가 등록됨)
13
+ * 2. listen() → Node http.Server 생성, 매 요청마다 hostname 확인 → 해당 MegaApp 의
14
+ * Fastify routing 함수에 위임
15
+ *
16
+ * vhost 매칭은 ADR-003 + ADR-067 (호스트 충돌 부팅 검증) 이미 처리됨.
17
+ */
18
+ export class MegaServer {
19
+ /**
20
+ * @param {{ port?: number, host?: string }} [opts] - listen 기본값 (listen() 인자로 override 가능)
21
+ */
22
+ constructor(opts = {}) {
23
+ /** @type {Map<string, import('./mega-app.js').MegaApp>} */
24
+ this._hostMap = new Map()
25
+ /** @type {import('./mega-app.js').MegaApp[]} */
26
+ this._apps = []
27
+ /** @type {import('node:http').Server | null} */
28
+ this._httpServer = null
29
+ this._opts = opts
30
+ }
31
+
32
+ /**
33
+ * MegaApp 마운트. 같은 host 가 두 앱에 매핑되면 throw (ADR-067 정합).
34
+ *
35
+ * WHY 2-pass: 충돌 검사를 **전체 host 에 대해 먼저 끝낸 뒤** `#hostMap` 을 변경한다. 한 번에 섞으면
36
+ * `hosts=['y','x']` 에서 'y' 를 등록한 직후 'x' 충돌로 throw 했을 때, 'y'→app 만 남고 app 은
37
+ * `_apps` 에 미등록되는 **부분 상태**가 생긴다(`#hostMap`↔`_apps` 불변식 깨짐 → 'y' 요청이 ready 안
38
+ * 된 Fastify 로 라우팅). 사전검증으로 mutate 전에 전부 막아 throw 시 상태를 원자적으로 보존한다.
39
+ *
40
+ * @param {import('./mega-app.js').MegaApp} app
41
+ */
42
+ mount(app) {
43
+ if (this._apps.includes(app)) return
44
+ // ① 전체 host 충돌 사전검증 — 어떤 host 든 충돌하면 #hostMap 을 손대기 전에 throw.
45
+ for (const host of app.hosts) {
46
+ const existing = this._hostMap.get(host)
47
+ if (existing && existing !== app) {
48
+ throw new MegaConfigError(
49
+ 'config.host_collision',
50
+ `Host '${host}' is claimed by both '${existing.name}' and '${app.name}'.`,
51
+ )
52
+ }
53
+ }
54
+ // ② 충돌 없음 확정 후에만 등록 — throw 가능 경로가 끝나 상태가 원자적으로 일관된다.
55
+ for (const host of app.hosts) {
56
+ this._hostMap.set(host, app)
57
+ }
58
+ this._apps.push(app)
59
+ }
60
+
61
+ /**
62
+ * @returns {boolean}
63
+ */
64
+ isListening() {
65
+ return this._httpServer?.listening === true
66
+ }
67
+
68
+ /**
69
+ * 모든 마운트된 MegaApp 의 Fastify 인스턴스 ready + HTTP 서버 listen.
70
+ * @param {{ port?: number, host?: string }} [opts]
71
+ */
72
+ async listen(opts = {}) {
73
+ if (this._apps.length === 0) {
74
+ throw new Error('MegaServer.listen: no MegaApps mounted')
75
+ }
76
+
77
+ // 모든 Fastify 인스턴스 ready (라우트 등록 완료 보장)
78
+ for (const app of this._apps) {
79
+ await app.fastify.ready()
80
+ }
81
+
82
+ this._httpServer = createHttpServer((req, res) => {
83
+ const hostHeader = String(req.headers.host || '')
84
+ const hostname = hostHeader.split(':')[0].toLowerCase()
85
+ const app = this._hostMap.get(hostname)
86
+ if (!app) {
87
+ res.statusCode = 404
88
+ res.setHeader('content-type', 'application/json')
89
+ res.end(
90
+ JSON.stringify({
91
+ ok: false,
92
+ error: {
93
+ code: 'host.not_mounted',
94
+ message: `No app mounted for host '${hostname}'. Known hosts: ${[...this._hostMap.keys()].join(', ')}`,
95
+ },
96
+ }),
97
+ )
98
+ return
99
+ }
100
+ // Fastify v5: `fastify.routing` 은 요청 디스패처 함수(preRouting) 자체다.
101
+ // 검증: node_modules/fastify/fastify.js — `routing: httpHandler`, 내부에서도
102
+ // `httpHandler(req, res)` 로 직접 호출. 따라서 routing(req, res) 로 호출한다.
103
+ app.fastify.routing(req, res)
104
+ })
105
+
106
+ // WS HTTP Upgrade 핸들오프 — 호스트 분기 후 해당 MegaApp 에 위임.
107
+ // Fastify 는 (websocket 플러그인 없으면) 'upgrade' 를 듣지 않으므로 여기서 직접 배선한다.
108
+ this._httpServer.on('upgrade', (req, socket, head) => {
109
+ const hostHeader = String(req.headers.host || '')
110
+ const hostname = hostHeader.split(':')[0].toLowerCase()
111
+ const app = this._hostMap.get(hostname)
112
+ if (!app) {
113
+ // 매핑 안 된 host 의 upgrade 는 거부 (소켓 파괴). HTTP 404 와 동일 정책.
114
+ // L4: destroy 전 error 가드. listener 없는 raw 소켓이 ECONNRESET 등으로 uncaught
115
+ // exception → 프로세스 크래시 하는 것을 막는다 (H1 과 동일 패턴). MegaServer 는
116
+ // 아직 pino 미통합이라 비치명적 소켓 에러는 console.warn 으로 명시 로그.
117
+ socket.on('error', (err) => {
118
+ console.warn('[MegaServer] raw socket error on unmapped-host upgrade:', err?.message ?? err)
119
+ })
120
+ socket.destroy()
121
+ return
122
+ }
123
+ app.handleUpgrade(req, socket, head)
124
+ })
125
+
126
+ const port = opts.port ?? this._opts.port ?? 3000
127
+ const host = opts.host ?? this._opts.host ?? '0.0.0.0'
128
+ const httpServer = this._httpServer
129
+ await new Promise((resolve, reject) => {
130
+ // WHY 이벤트 배선: node http.Server.listen 의 콜백은 err 인자를 받지 않는다
131
+ // (() => void). 바인딩 실패(EADDRINUSE 등)는 'error' 이벤트로만 발생하므로,
132
+ // 'error' 리스너가 없으면 unhandled error 로 프로세스가 크래시/행 한다.
133
+ // 따라서 'listening'/'error' 를 once() 로 배선하고, 어느 한쪽이 발생하면
134
+ // 다른 쪽 리스너를 제거해 누수를 막는다.
135
+ const onError = (/** @type {Error} */ err) => {
136
+ httpServer.removeListener('listening', onListening)
137
+ reject(err)
138
+ }
139
+ const onListening = () => {
140
+ httpServer.removeListener('error', onError)
141
+ resolve(undefined)
142
+ }
143
+ httpServer.once('error', onError)
144
+ httpServer.once('listening', onListening)
145
+ httpServer.listen({ port, host })
146
+ })
147
+ }
148
+
149
+ /**
150
+ * 모든 마운트된 MegaApp close (역순 — 마지막 mount 부터) → HTTP 서버 종료.
151
+ * graceful 처리는 MegaCluster + MegaShutdown 에서 강화.
152
+ *
153
+ * # 종료 순서 (M1)
154
+ * 각 app 의 WS 클라이언트를 **먼저** close(1001) 한 뒤 httpServer 를 닫는다. Node 의
155
+ * `http.Server.close` 콜백은 업그레이드된 WS 소켓을 포함한 모든 연결이 닫혀야 발생하므로,
156
+ * httpServer 를 먼저 await 하면 활성 WS 연결이 있을 때 영원히 끝나지 않는다(hang). single
157
+ * 모드(MegaApp.close)와 동일하게 WS → HTTP 순으로 닫아 일관성을 맞춘다.
158
+ */
159
+ async close() {
160
+ // ① 각 app close — 내부에서 활성 WS 클라이언트 close(1001) + Fastify close (역순 LIFO).
161
+ for (const app of [...this._apps].reverse()) {
162
+ await app.close()
163
+ }
164
+ // ② 그 다음 HTTP 서버 종료 — WS 소켓이 모두 닫혔으므로 콜백이 즉시 발생한다.
165
+ if (this._httpServer && this._httpServer.listening) {
166
+ await new Promise((resolve, reject) => {
167
+ this._httpServer.close((err) => (err ? reject(err) : resolve(undefined)))
168
+ })
169
+ }
170
+ }
171
+
172
+ /** 등록된 호스트 목록 (디버그·테스트용). */
173
+ get hosts() {
174
+ return [...this._hostMap.keys()]
175
+ }
176
+ }
@@ -0,0 +1,41 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * MegaService — 도메인 비즈니스 로직 베이스.
5
+ *
6
+ * 라우트 핸들러(인라인 함수 또는 정적 메서드 ref)는 직접 모델을 호출하지 않고 항상 서비스를 거쳐야
7
+ * 한다(ADR-022). ESLint custom rule `mega/no-direct-model-import`(`mega-framework/eslint-plugin`,
8
+ * eslint.config.js 에 배선)가 lint 시점에 routes/controllers → models 직접 import 를 차단한다.
9
+ *
10
+ * 서비스는 요청 컨텍스트 ctx 와 함께 인스턴스화되며, this.ctx / this.app / this.log /
11
+ * this.services 로 접근. 다른 서비스 호출은 this.services.<name> 으로 합성.
12
+ *
13
+ * 자동 DI(ADR-148): `apps/<app>/services/<name>-service.js` 를 부팅 시 로드해 두면 핸들러·다른 서비스가
14
+ * `ctx.services.<name>` / `this.services.<name>` 로 요청별 인스턴스를 받는다(첫 접근 시 lazy 생성·요청 내 캐시).
15
+ */
16
+ export class MegaService {
17
+ /**
18
+ * @param {Record<string, any>} ctx - 요청 컨텍스트 (req·log·services·db·cache·bus 등)
19
+ * @param {Object} [opts]
20
+ * @param {Object} [opts.app] - MegaApp 인스턴스 (옵션)
21
+ */
22
+ constructor(ctx, opts = {}) {
23
+ if (ctx == null || typeof ctx !== 'object') {
24
+ throw new Error('MegaService: ctx is required')
25
+ }
26
+ /** @type {Record<string, any>} */
27
+ this.ctx = ctx
28
+ /** @type {Object | null} */
29
+ this.app = opts.app ?? null
30
+ }
31
+
32
+ /** 요청·인스턴스 로거. ctx.log 가 없으면 console fallback. */
33
+ get log() {
34
+ return this.ctx?.log ?? console
35
+ }
36
+
37
+ /** 다른 서비스 호출용. ctx.services 가 없으면 빈 객체. */
38
+ get services() {
39
+ return this.ctx?.services ?? {}
40
+ }
41
+ }
@@ -0,0 +1,196 @@
1
+ // @ts-check
2
+ /**
3
+ * 마이그레이션 러너 (ADR-149) — `apps/<app>/migrations/<ts>-<name>.js` 의 up/down 을
4
+ * 대상 DB 에 적용·롤백하고, 적용 이력을 대상 DB 의 `mega_migrations` 테이블로 추적한다.
5
+ *
6
+ * 러너는 **어댑터에 비의존적**이다 — 호출자(`runMigrateHost`)가 연결된 DB 어댑터(`{ query, withTransaction }`)를
7
+ * `db` 로 넘긴다. 마이그레이션 파일의 `up(db)/down(db)` 는 이 어댑터를 받아 SQL 을 실행한다. 어댑터가
8
+ * `withTransaction` 을 지원하면 각 마이그레이션을 트랜잭션으로 감싸 부분 실패를 롤백한다(postgres DDL-in-tx).
9
+ *
10
+ * 부기 SQL(테이블 생성·SELECT·INSERT·DELETE)은 표준 SQL 만 쓰고, 값(마이그레이션 name·applied_at)은
11
+ * **framework-controlled 라 따옴표가 불가능**(name 은 {@link MIGRATION_FILE_RE} 검증, applied_at 은 ISO)하므로
12
+ * placeholder(`$1` vs `?`) dialect 분기 없이 인라인해도 인젝션-안전하다.
13
+ *
14
+ * @module core/migration-runner
15
+ */
16
+ import { readdirSync } from 'node:fs'
17
+ import { join, resolve as pathResolve } from 'node:path'
18
+ import { pathToFileURL } from 'node:url'
19
+ import { MegaConfigError } from '../errors/config-error.js'
20
+
21
+ /** 적용 이력 테이블 이름. */
22
+ export const MIGRATIONS_TABLE = 'mega_migrations'
23
+
24
+ /** 마이그레이션 파일명 형식 — `<YYYYMMDDHHmmss>-<kebab>.js`. generator(ADR-012)가 이 prefix 를 생성. */
25
+ export const MIGRATION_FILE_RE = /^\d{14}-[a-z0-9][a-z0-9-]*\.js$/
26
+
27
+ /**
28
+ * @typedef {{ query: (sql: string, params?: any[]) => Promise<any>, withTransaction?: (fn: () => Promise<any>) => Promise<any> }} MigrationDb
29
+ */
30
+
31
+ /**
32
+ * @typedef {{ name: string, file: string, app: string, absPath: string }} MigrationFile
33
+ */
34
+
35
+ /**
36
+ * 모든 앱의 `migrations/` 폴더를 스캔해 마이그레이션 파일 목록을 **타임스탬프(파일명 prefix) 오름차순**으로
37
+ * 반환한다. 형식(`MIGRATION_FILE_RE`)에 맞지 않는 파일·`*.test.js` 는 건너뛴다. 폴더 부재는 정상.
38
+ *
39
+ * @param {{ projectRoot: string, appNames: string[] }} opts
40
+ * @returns {MigrationFile[]}
41
+ */
42
+ export function collectMigrationFiles({ projectRoot, appNames }) {
43
+ /** @type {MigrationFile[]} */
44
+ const out = []
45
+ for (const app of appNames) {
46
+ const dir = join(projectRoot, 'apps', app, 'migrations')
47
+ let files
48
+ try {
49
+ files = readdirSync(dir)
50
+ } catch (err) {
51
+ if (/** @type {any} */ (err).code === 'ENOENT') continue
52
+ throw err
53
+ }
54
+ for (const file of files) {
55
+ if (!MIGRATION_FILE_RE.test(file)) continue // ts-prefix 형식만(.test.js 등 자동 제외)
56
+ out.push({ name: file.replace(/\.js$/, ''), file, app, absPath: pathResolve(join(dir, file)) })
57
+ }
58
+ }
59
+ // 전역 타임스탬프 정렬 — name = `<ts>-<kebab>` 라 사전식 = 시간순.
60
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * 마이그레이션 모듈 로드 + up/down 검증.
66
+ * @param {string} absPath
67
+ * @returns {Promise<{ up: Function, down: Function }>}
68
+ * @throws {MegaConfigError} import 실패 / up·down 누락.
69
+ */
70
+ export async function loadMigration(absPath) {
71
+ let mod
72
+ try {
73
+ mod = await import(pathToFileURL(absPath).href)
74
+ } catch (err) {
75
+ throw new MegaConfigError('migration.file_load_failed', `Failed to load migration '${absPath}': ${/** @type {any} */ (err).message}`, { cause: err })
76
+ }
77
+ if (typeof mod.up !== 'function' || typeof mod.down !== 'function') {
78
+ throw new MegaConfigError('migration.invalid', `Migration '${absPath}' must export async function up(db) and down(db).`)
79
+ }
80
+ return { up: mod.up, down: mod.down }
81
+ }
82
+
83
+ /**
84
+ * 이력 테이블 보장(idempotent). 표준 SQL — postgres/maria/sqlite 공통.
85
+ * @param {MigrationDb} db
86
+ * @returns {Promise<void>}
87
+ */
88
+ async function ensureTable(db) {
89
+ await db.query(`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name VARCHAR(255) PRIMARY KEY, applied_at VARCHAR(64) NOT NULL)`)
90
+ }
91
+
92
+ /**
93
+ * 적용된 마이그레이션 name 집합.
94
+ * @param {MigrationDb} db
95
+ * @returns {Promise<Set<string>>}
96
+ */
97
+ async function appliedSet(db) {
98
+ const res = await db.query(`SELECT name FROM ${MIGRATIONS_TABLE}`)
99
+ const rows = res?.rows ?? (Array.isArray(res) ? res : [])
100
+ return new Set(rows.map((/** @type {any} */ r) => r.name))
101
+ }
102
+
103
+ /**
104
+ * 적용 기록 INSERT. name(검증된 [\d a-z -])·iso(ISO)는 따옴표 불가라 인라인 안전(placeholder dialect 회피).
105
+ * @param {MigrationDb} db @param {string} name @param {string} appliedAtIso
106
+ */
107
+ async function recordApplied(db, name, appliedAtIso) {
108
+ await db.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ('${name}', '${appliedAtIso}')`)
109
+ }
110
+
111
+ /**
112
+ * 적용 기록 DELETE(롤백 시).
113
+ * @param {MigrationDb} db @param {string} name
114
+ */
115
+ async function removeApplied(db, name) {
116
+ await db.query(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = '${name}'`)
117
+ }
118
+
119
+ /**
120
+ * 어댑터가 트랜잭션을 지원하면 fn 을 트랜잭션으로 감싸 실행한다. postgres 는 AsyncLocalStorage 로 tx
121
+ * 컨텍스트를 추적하므로 fn 안의 `db.query` 가 같은 트랜잭션 클라이언트로 라우팅된다(ADR-106).
122
+ * @param {MigrationDb} db @param {() => Promise<void>} fn
123
+ */
124
+ async function runInTransaction(db, fn) {
125
+ if (typeof db.withTransaction === 'function') {
126
+ await db.withTransaction(async () => {
127
+ await fn()
128
+ })
129
+ } else {
130
+ await fn()
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 미적용(pending) 마이그레이션을 타임스탬프 순으로 모두 적용한다. 각 마이그레이션의 up + 이력 기록을
136
+ * 한 트랜잭션으로 묶어 원자적으로 처리한다.
137
+ *
138
+ * @param {{ db: MigrationDb, projectRoot: string, appNames: string[], now?: () => string, log?: { info?: Function } }} opts
139
+ * - now: 적용 시각 ISO 문자열 공급자(테스트 주입용, 기본 `new Date().toISOString()`).
140
+ * @returns {Promise<{ applied: string[] }>}
141
+ */
142
+ export async function migrateUp({ db, projectRoot, appNames, now = () => new Date().toISOString(), log }) {
143
+ await ensureTable(db)
144
+ const applied = await appliedSet(db)
145
+ const pending = collectMigrationFiles({ projectRoot, appNames }).filter((m) => !applied.has(m.name))
146
+ /** @type {string[]} */
147
+ const done = []
148
+ for (const m of pending) {
149
+ const { up } = await loadMigration(m.absPath)
150
+ const appliedAt = now()
151
+ await runInTransaction(db, async () => {
152
+ await up(db)
153
+ await recordApplied(db, m.name, appliedAt)
154
+ })
155
+ log?.info?.({ migration: m.name }, 'migrate.up applied')
156
+ done.push(m.name)
157
+ }
158
+ return { applied: done }
159
+ }
160
+
161
+ /**
162
+ * 가장 최근 적용된 마이그레이션 1개를 롤백한다(down + 이력 삭제, 한 트랜잭션). 적용분이 없으면 no-op.
163
+ * 적용 이력에 있으나 파일이 사라진 항목은 down 을 실행할 수 없어 건너뛴다.
164
+ *
165
+ * @param {{ db: MigrationDb, projectRoot: string, appNames: string[], log?: { info?: Function } }} opts
166
+ * @returns {Promise<{ rolledBack: string | null }>}
167
+ */
168
+ export async function migrateDown({ db, projectRoot, appNames, log }) {
169
+ await ensureTable(db)
170
+ const applied = await appliedSet(db)
171
+ const appliedFiles = collectMigrationFiles({ projectRoot, appNames }).filter((m) => applied.has(m.name))
172
+ const last = appliedFiles[appliedFiles.length - 1]
173
+ if (!last) return { rolledBack: null }
174
+ const { down } = await loadMigration(last.absPath)
175
+ await runInTransaction(db, async () => {
176
+ await down(db)
177
+ await removeApplied(db, last.name)
178
+ })
179
+ log?.info?.({ migration: last.name }, 'migrate.down rolled back')
180
+ return { rolledBack: last.name }
181
+ }
182
+
183
+ /**
184
+ * 적용/미적용 마이그레이션 목록(타임스탬프 순).
185
+ * @param {{ db: MigrationDb, projectRoot: string, appNames: string[] }} opts
186
+ * @returns {Promise<{ applied: string[], pending: string[] }>}
187
+ */
188
+ export async function migrateStatus({ db, projectRoot, appNames }) {
189
+ await ensureTable(db)
190
+ const applied = await appliedSet(db)
191
+ const all = collectMigrationFiles({ projectRoot, appNames })
192
+ return {
193
+ applied: all.filter((m) => applied.has(m.name)).map((m) => m.name),
194
+ pending: all.filter((m) => !applied.has(m.name)).map((m) => m.name),
195
+ }
196
+ }