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,341 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaPostgresAdapter — PostgreSQL 어댑터 (`pg` = node-postgres 래퍼, ADR-106).
4
+ *
5
+ * Sqlite(ADR-105)에 이어 두 번째 SQL 어댑터이자 **첫 docker 실 컨테이너 통합** 대상.
6
+ * Sqlite 의 manual 트랜잭션 패턴을 잇되, Postgres 가 SAVEPOINT 를 지원하므로 nested 정책이
7
+ * Sqlite 의 "재진입 거부" 와 분기한다(아래 "트랜잭션 패턴" 참조).
8
+ *
9
+ * # 표준 표면 (MegaDbAdapter 상속)
10
+ * - `_connect()` — `new Pool(cfg)` (driver 는 connect 시점 lazy import) + `SELECT 1` 검증.
11
+ * - `_disconnect()`— `pool.end()` (모든 클라이언트 drain + 종료).
12
+ * - `_native()` — `pg` Pool 인스턴스(raw handle, ADR-009). `MegaModel.db` 가 노출.
13
+ * - `healthCheck()`— `pool.query('SELECT 1')` 실행으로 실제 응답성 확인.
14
+ * - `getStats()` — 베이스 stats + 풀 통계(total/idle/waiting).
15
+ * - `withTransaction(fn)` — 명시적 트랜잭션 경계 (ADR-010). 아래 "트랜잭션 패턴" 참조.
16
+ *
17
+ * # 트랜잭션 패턴 — 풀 클라이언트 1개 + manual `BEGIN/COMMIT/ROLLBACK`
18
+ * `pool.connect()` 으로 클라이언트 1개를 잡아 그 위에서 `BEGIN → fn(client) → COMMIT`(성공) /
19
+ * `ROLLBACK`(실패) 한다. `fn` 에는 **트랜잭션 컨텍스트 클라이언트**(같은 connection)를 넘겨,
20
+ * 사용자가 그 client 로 쿼리하면 전부 한 트랜잭션에 묶인다. `client.release()` 는 finally 에서
21
+ * 반드시 호출해 풀로 반납한다(누수 방지). ROLLBACK 자체 실패(드묾)는 원본 에러를 가리지 않도록
22
+ * 격리 후 원본 re-throw(ADR-010).
23
+ *
24
+ * # nested 트랜잭션 정책 — SAVEPOINT (Sqlite 와 분기, ADR-106)
25
+ * Sqlite(단일 연결, ADR-105)는 nested 를 **거부**하지만, Postgres 는 SAVEPOINT 로 부분 롤백을
26
+ * 지원한다. `withTransaction` 안에서 `withTransaction` 을 다시 부르면(nested), 새 풀 연결을 잡지
27
+ * 않고 **같은 클라이언트에 `SAVEPOINT sp_<depth>`** 를 건다 — 성공 시 `RELEASE SAVEPOINT`,
28
+ * 실패 시 `ROLLBACK TO SAVEPOINT` 후 re-throw 하여 바깥 트랜잭션은 살린다.
29
+ *
30
+ * **컨텍스트 추적은 `AsyncLocalStorage`** 로 한다(인스턴스 필드 카운터 X). store 에 `{ client, depth }`
31
+ * 를 담고 nesting 마다 새 store 로 `als.run` 격리하므로, **동시 트랜잭션**(서로 다른 풀 연결을 잡는
32
+ * top-level 호출)은 각자 독립 store 를 가져 depth 가 섞이지 않는다(async race-free). 만약 인스턴스
33
+ * 필드로 depth 를 셌다면 두 트랜잭션의 `await` 인터리빙에서 카운터가 오염됐을 것이다 — ALS 가 그
34
+ * 문제를 구조적으로 제거한다. 별도 풀 연결을 잡지 않으므로 `max:1` 풀에서도 nested 가 deadlock 되지
35
+ * 않는다.
36
+ *
37
+ * ⚠️ **"race-free" 의 경계**: 위 race-free 는 **서로 다른 top-level 호출 사이의
38
+ * 동시성**에만 해당한다. **단일 트랜잭션 내부**에서 sibling nested 를 `Promise.all` 등으로 **병렬**
39
+ * 실행하는 것은 **미지원**이다 — 같은 클라이언트(단일 connection) 위에 SAVEPOINT 이름이 `depth` 로만
40
+ * 결정되므로 두 sibling 이 동시에 `depth+1` 을 보면 둘 다 `sp_1` 을 만들어 충돌하고, 애초에 pg 단일
41
+ * connection 은 쿼리를 직렬화하므로 병렬 이득도 없다. nested 는 **순차 호출**(await 로 한 번에 하나)할 것.
42
+ *
43
+ * # 설정 (services.databases.<key>) — 통합 옵션 구조 (ADR-109)
44
+ * ```js
45
+ * services: {
46
+ * databases: {
47
+ * primary: {
48
+ * driver: 'postgres',
49
+ * // (a) 연결 — url 또는 discrete (상호 배타)
50
+ * url: 'postgres://user:pw@host:5432/db', // 또는 ↓
51
+ * host: 'localhost', port: 5432, user: 'mega', password: '...', database: 'mega_test',
52
+ * // (b) pool — 공통 풀 인터페이스 (드라이버 키로 매핑)
53
+ * pool: { min: 0, max: 10, idleTimeoutMs: 10000, acquireTimeoutMs: 0, maxLifetimeMs: 0 },
54
+ * // (c) options — pg 특화 passthrough (그대로 pg.Pool 에 전달)
55
+ * options: { ssl: false, statement_timeout: 30000, application_name: 'mega', keepAlive: true },
56
+ * },
57
+ * },
58
+ * }
59
+ * ```
60
+ * `url`(또는 deprecated 별칭 `connectionString`) **XOR** discrete 연결필드(host/port/user/password/
61
+ * database). 둘 다 없으면 `adapter.connection_required`, 동시 지정은 `adapter.connection_conflict`
62
+ * (ADR-109). `pool`/`options` 는 어느 연결 모드와도 조합 가능. pool 매핑: min→min, max→max,
63
+ * idleTimeoutMs→idleTimeoutMillis, acquireTimeoutMs→connectionTimeoutMillis, maxLifetimeMs→
64
+ * maxLifetimeSeconds(ms÷1000). 비밀번호·url 은 healthCheck/getStats/에러 details 에 절대 노출하지
65
+ * 않는다(시크릿 마스킹 정합).
66
+ *
67
+ * @module adapters/postgres-adapter
68
+ */
69
+ import { AsyncLocalStorage } from 'node:async_hooks'
70
+ import { MegaDbAdapter } from './mega-db-adapter.js'
71
+ import { resolveConnection, normalizePool, assertPlainObject, PG_POOL_SPEC } from './adapter-options.js'
72
+ import * as Registry from './registry.js'
73
+
74
+ /**
75
+ * @typedef {object} PostgresConfig
76
+ * @property {string} [driver] - 'postgres' (매니저가 사용 — 어댑터는 무시).
77
+ * @property {string} [url] - `postgres://user:pw@host:port/db` (discrete 필드와 배타).
78
+ * @property {string} [connectionString] - `url` 의 deprecated 별칭 (하위 호환).
79
+ * @property {string} [host] @property {number} [port] @property {string} [user]
80
+ * @property {string} [password] @property {string} [database]
81
+ * @property {{ min?: number, max?: number, idleTimeoutMs?: number, acquireTimeoutMs?: number, maxLifetimeMs?: number }} [pool] - 공통 풀 인터페이스.
82
+ * @property {Record<string, any>} [options] - pg.Pool passthrough (ssl, statement_timeout, application_name, keepAlive, query_timeout, …).
83
+ */
84
+
85
+ /**
86
+ * @typedef {object} PgTxContext - AsyncLocalStorage 에 담기는 트랜잭션 컨텍스트.
87
+ * @property {import('pg').PoolClient} client - 이 트랜잭션을 소유한 풀 클라이언트.
88
+ * @property {number} depth - nesting 깊이 (top-level=0, 첫 nested=1, ...).
89
+ */
90
+
91
+ export class MegaPostgresAdapter extends MegaDbAdapter {
92
+ /** @type {import('pg').Pool | null} 연결된 Pool 인스턴스 (connect 후에만). */
93
+ #pool = null
94
+ /** @type {import('pg').PoolConfig} _connect 에서 `new Pool()` 에 넘길 설정 (생성자에서 구성·고정). */
95
+ #poolConfig
96
+ /**
97
+ * 진행 중 트랜잭션 컨텍스트 추적기. nested 호출이 같은 클라이언트를 재사용하도록 한다.
98
+ * 인스턴스당 1개 — 동시 top-level 트랜잭션은 각자 `run` 으로 격리된 store 를 가진다(async race-free).
99
+ * @type {AsyncLocalStorage<PgTxContext>}
100
+ */
101
+ #txContext = new AsyncLocalStorage()
102
+
103
+ /**
104
+ * @param {PostgresConfig} [config] - services.databases.<key> 설정.
105
+ * @throws {import('../errors/http-errors.js').MegaValidationError} `adapter.connection_required` - url·discrete 둘 다 없음.
106
+ * @throws {import('../errors/http-errors.js').MegaValidationError} `adapter.connection_conflict` - url + discrete 동시 지정.
107
+ * @throws {import('../errors/http-errors.js').MegaValidationError} `adapter.invalid_option` - 옵션 타입/범위 오류.
108
+ */
109
+ constructor(config = /** @type {any} */ ({})) {
110
+ super(config)
111
+
112
+ // 연결 모드(url XOR discrete) 결정 + 충돌·필수 검증 (ADR-109 공용 헬퍼).
113
+ const conn = resolveConnection(config, { driver: 'postgres', dbKey: 'database', dbConflictsWithUrl: true })
114
+ // 공통 풀 인터페이스 → pg 풀 키 매핑 + 부팅 시 fail-fast 검증.
115
+ const poolOpts = normalizePool(config.pool, PG_POOL_SPEC, 'postgres')
116
+ assertPlainObject('options', config.options, { driver: 'postgres' })
117
+
118
+ // pg.Pool 은 connectionString 과 풀 옵션을 한 객체로 받는다. undefined 키는 pg 디폴트로 떨어진다.
119
+ /** @type {import('pg').PoolConfig} */
120
+ const poolConfig = {}
121
+ if (conn.url !== undefined) poolConfig.connectionString = conn.url
122
+ if (conn.host !== undefined) poolConfig.host = conn.host
123
+ if (conn.port !== undefined) poolConfig.port = conn.port
124
+ if (conn.user !== undefined) poolConfig.user = conn.user
125
+ if (conn.password !== undefined) poolConfig.password = conn.password
126
+ if (conn.database !== undefined) poolConfig.database = conn.database
127
+ // options(드라이버 특화 passthrough) 먼저, pool(정규화) 나중 — 겹치면 명시 pool 인터페이스가 이긴다.
128
+ if (config.options !== undefined) Object.assign(poolConfig, config.options)
129
+ Object.assign(poolConfig, poolOpts)
130
+ this.#poolConfig = poolConfig
131
+ }
132
+
133
+ /**
134
+ * `pg` Pool 생성 + `SELECT 1` 로 실제 연결 검증.
135
+ * driver 는 **connect 시점에 lazy import** — 모듈 import(배럴 경유 자기등록)만으로는 pg 를
136
+ * 로드하지 않아, 본 어댑터를 안 쓰는 환경이 pg 설치를 강제받지 않는다(Sqlite 패턴 정합, ADR-105).
137
+ *
138
+ * @protected
139
+ * @returns {Promise<void>}
140
+ */
141
+ async _connect() {
142
+ const pg = await import('pg')
143
+ // pg 는 CJS — ESM interop 으로 named(`pg.Pool`)·default 둘 다 노출될 수 있어 방어적으로 둘 다 본다.
144
+ const Pool = pg.Pool ?? /** @type {any} */ (pg.default)?.Pool
145
+ const pool = new Pool(this.#poolConfig)
146
+ // connect 직후 실제 1회 쿼리로 연결 검증 — 잘못된 자격증명/호스트를 부팅 시점에 잡는다(fail-fast).
147
+ // 실패하면 leak 방지를 위해 pool 을 닫고 원본 에러 전파.
148
+ try {
149
+ await pool.query('SELECT 1')
150
+ } catch (err) {
151
+ try {
152
+ await pool.end()
153
+ } catch (endErr) {
154
+ // 검증 실패 후 정리(pool.end) 실패는 비치명적 — 원본 연결 에러가 진짜 원인.
155
+ console.warn('[MegaPostgresAdapter] pool.end() failed after connect validation error (original error wins):', endErr)
156
+ }
157
+ throw err
158
+ }
159
+ this.#pool = pool
160
+ }
161
+
162
+ /**
163
+ * Pool 종료(모든 클라이언트 drain + 종료). 베이스 상태 머신이 connected 상태에서만 호출 보장.
164
+ * @protected
165
+ * @returns {Promise<void>}
166
+ */
167
+ async _disconnect() {
168
+ if (this.#pool !== null) {
169
+ const pool = this.#pool
170
+ this.#pool = null
171
+ await pool.end()
172
+ }
173
+ }
174
+
175
+ /**
176
+ * raw Pool handle (ADR-009). `connect()` 후에만 베이스 `native` getter 가 호출한다.
177
+ * @protected
178
+ * @returns {import('pg').Pool}
179
+ */
180
+ _native() {
181
+ if (this.#pool === null) {
182
+ // 베이스 native getter 가 state 검증을 먼저 하므로 정상 경로에선 도달 안 함 — 방어.
183
+ return this._notImplemented('native')
184
+ }
185
+ return this.#pool
186
+ }
187
+
188
+ /**
189
+ * 헬스 체크 — 실제 `SELECT 1` 으로 응답성 확인 (베이스 디폴트는 상태만 반영).
190
+ * 실패는 throw 없이 `ok:false` + 사유(베이스 계약). 비밀번호/connectionString 은 노출하지 않는다.
191
+ *
192
+ * @returns {Promise<{ ok: boolean, driver: 'postgres', state: string, error?: string }>}
193
+ */
194
+ async healthCheck() {
195
+ if (this.state !== 'connected' || this.#pool === null) {
196
+ return { ok: false, driver: 'postgres', state: this.state }
197
+ }
198
+ try {
199
+ const res = await this.#pool.query('SELECT 1 AS ok')
200
+ const ok = res.rows?.[0]?.ok === 1
201
+ return { ok, driver: 'postgres', state: this.state }
202
+ } catch (err) {
203
+ return {
204
+ ok: false,
205
+ driver: 'postgres',
206
+ state: this.state,
207
+ error: err instanceof Error ? err.message : String(err),
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * 누적 통계 + 풀 통계(total/idle/waiting). 연결 전이면 풀 통계는 0.
214
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, idle: number, waiting: number } }}
215
+ */
216
+ getStats() {
217
+ const pool = this.#pool
218
+ return {
219
+ ...super.getStats(),
220
+ driver: 'postgres',
221
+ pool: {
222
+ total: pool?.totalCount ?? 0,
223
+ idle: pool?.idleCount ?? 0,
224
+ waiting: pool?.waitingCount ?? 0,
225
+ },
226
+ }
227
+ }
228
+
229
+ /**
230
+ * 명시적 트랜잭션 경계 (ADR-010, ADR-106). top-level 은 풀 클라이언트 1개 위 manual
231
+ * `BEGIN/COMMIT/ROLLBACK`, nested 는 같은 클라이언트 위 `SAVEPOINT`(모듈 docstring 참조).
232
+ *
233
+ * `fn` 은 **트랜잭션 컨텍스트 클라이언트**(pg PoolClient)를 인자로 받는다. 성공 시 COMMIT(또는
234
+ * RELEASE SAVEPOINT) 후 `fn` 반환값을 그대로 돌려주고, throw 시 ROLLBACK(또는 ROLLBACK TO
235
+ * SAVEPOINT) 후 원본 에러를 re-throw 한다.
236
+ *
237
+ * hook(`onCallStart/onCallEnd`) + 상태 검증 + stats 누적은 `_instrument` 가 처리한다(ADR-077).
238
+ *
239
+ * @template T
240
+ * @param {(client: import('pg').PoolClient) => Promise<T> | T} fn
241
+ * @returns {Promise<T>}
242
+ */
243
+ async withTransaction(fn) {
244
+ return this._instrument('withTransaction', { table: undefined }, async () => {
245
+ const existing = this.#txContext.getStore()
246
+ // nested — 진행 중 트랜잭션이 있으면 같은 클라이언트에 SAVEPOINT 를 건다(Sqlite 와 분기).
247
+ if (existing !== undefined) {
248
+ return this.#runSavepoint(existing, fn)
249
+ }
250
+ // top-level — 풀에서 클라이언트 1개 획득 후 BEGIN/COMMIT/ROLLBACK.
251
+ const pool = /** @type {import('pg').Pool} */ (this.#pool)
252
+ const client = await pool.connect()
253
+ try {
254
+ await client.query('BEGIN')
255
+ let result
256
+ try {
257
+ result = await this.#txContext.run({ client, depth: 0 }, () => fn(client))
258
+ } catch (err) {
259
+ await this.#rollback(client)
260
+ throw err
261
+ }
262
+ await client.query('COMMIT')
263
+ return result
264
+ } finally {
265
+ // 성공/실패 무관 — 반드시 풀로 반납(누수 방지).
266
+ client.release()
267
+ }
268
+ })
269
+ }
270
+
271
+ /**
272
+ * 계측된 SQL 쿼리 (ADR-138). 진행 중 트랜잭션이 있으면 그 컨텍스트의 클라이언트를, 없으면 풀을
273
+ * 통해 실행한다 — 트랜잭션 안에서 호출해도 같은 connection 을 써 격리가 유지된다(풀의 다른
274
+ * 커넥션을 잡아 트랜잭션 밖으로 새지 않음). `_instrument('query', …)` 가 자동 span(`postgres.query`,
275
+ * `db.system.name`/`db.query.text`/`mega.rows_affected`)·stats·상태 검증을 처리한다. 결과는 pg 의
276
+ * `QueryResult`(`{ rows, rowCount, … }`)를 그대로 돌려준다(ADR-009).
277
+ *
278
+ * @param {string} sql - 파라미터화된 SQL(placeholder `$1` 보존).
279
+ * @param {any[]} [params] - placeholder 바인딩 값.
280
+ * @returns {Promise<import('pg').QueryResult>}
281
+ */
282
+ async query(sql, params) {
283
+ return this._instrument(
284
+ 'query',
285
+ this._queryStartAttrs(sql),
286
+ async () => {
287
+ const ctx = this.#txContext.getStore()
288
+ const runner = ctx !== undefined ? ctx.client : /** @type {import('pg').Pool} */ (this.#pool)
289
+ return runner.query(sql, params)
290
+ },
291
+ (res) => ({ 'mega.rows_affected': typeof res?.rowCount === 'number' ? res.rowCount : 0 }),
292
+ )
293
+ }
294
+
295
+ /**
296
+ * nested 트랜잭션 — 진행 중 컨텍스트의 클라이언트에 SAVEPOINT 를 걸어 부분 롤백을 지원한다.
297
+ * `depth` 는 store 에서 단조 증가하므로 SAVEPOINT 이름이 nesting 별로 유일하고, 정수라 SQL
298
+ * 인젝션 위험이 없다.
299
+ *
300
+ * @template T
301
+ * @param {PgTxContext} ctx - 진행 중 트랜잭션 컨텍스트(부모).
302
+ * @param {(client: import('pg').PoolClient) => Promise<T> | T} fn
303
+ * @returns {Promise<T>}
304
+ */
305
+ async #runSavepoint(ctx, fn) {
306
+ const depth = ctx.depth + 1
307
+ const name = `sp_${depth}`
308
+ const client = ctx.client
309
+ await client.query(`SAVEPOINT ${name}`)
310
+ let result
311
+ try {
312
+ result = await this.#txContext.run({ client, depth }, () => fn(client))
313
+ } catch (err) {
314
+ // 부분 롤백 — SAVEPOINT 이후 변경만 되돌리고 바깥 트랜잭션은 유지. ROLLBACK TO 실패는 격리.
315
+ try {
316
+ await client.query(`ROLLBACK TO SAVEPOINT ${name}`)
317
+ } catch (spErr) {
318
+ console.warn(`[MegaPostgresAdapter] ROLLBACK TO SAVEPOINT ${name} failed (original error wins):`, spErr)
319
+ }
320
+ throw err
321
+ }
322
+ await client.query(`RELEASE SAVEPOINT ${name}`)
323
+ return result
324
+ }
325
+
326
+ /**
327
+ * top-level 트랜잭션 ROLLBACK — 실패해도 원본 에러를 가리지 않도록 격리 후 무시.
328
+ * @param {import('pg').PoolClient} client @returns {Promise<void>}
329
+ */
330
+ async #rollback(client) {
331
+ try {
332
+ await client.query('ROLLBACK')
333
+ } catch (err) {
334
+ console.warn('[MegaPostgresAdapter] ROLLBACK failed (original error wins):', err)
335
+ }
336
+ }
337
+ }
338
+
339
+ // 빌트인 driver 자기등록 (ADR-044) — 배럴(`adapters/index.js`)이 본 모듈을 import 하면 트리거된다.
340
+ // pg 는 _connect() 의 lazy import 까지 로드되지 않으므로 등록은 안전(미사용 환경 비강제).
341
+ Registry.register('postgres', MegaPostgresAdapter)
@@ -0,0 +1,331 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaRedisAdapter — Redis 캐시 어댑터 (`ioredis` 래퍼, ADR-110).
4
+ *
5
+ * **첫 cache 도메인 어댑터**(`MegaCacheAdapter` 첫 구체). DB 어댑터와 달리
6
+ * `get/set/del/has` key-value 인터페이스를 구현하고, 트랜잭션·SQL 개념이 없다.
7
+ *
8
+ * # 표준 표면 (MegaCacheAdapter 상속)
9
+ * - `_connect()` — `new Redis({...lazyConnect})` (driver 는 connect 시점 lazy import) +
10
+ * `client.connect()` + `client.ping()` 로 실제 응답 검증.
11
+ * - `_disconnect()`— `client.quit()` (진행 중 명령 flush 후 graceful 종료).
12
+ * - `_native()` — `ioredis` Redis 인스턴스(raw handle, ADR-009). 사용자가 직접 ioredis API 접근.
13
+ * - `healthCheck()`— `client.ping()` 으로 실제 응답성 확인 (베이스 디폴트=상태만 → override).
14
+ * - `getStats()` — 베이스 stats + redis 특화(driver/db/연결 status).
15
+ * - `get/set/del/has` — JSON 직렬화 + TTL(SETEX 의미는 `SET ... EX`).
16
+ *
17
+ * # pool 미지원 (중요 — DB 어댑터와 분기, ADR-110)
18
+ * Postgres/Maria/Mongo 는 connection pool 을 쓰지만, Redis 는 **단일 연결 + (옵션) cluster** 모델이라
19
+ * 공통 풀 인터페이스(`{ min, max, idleTimeoutMs, ... }`)가 맞지 않는다. 따라서 본 어댑터는
20
+ * `config.pool` 을 **명시적으로 거부**한다(`adapter.invalid_option`) — silent 무시가 아니라
21
+ * fail-fast로, 사용자가 풀을 기대하다 동작 안 하는 혼선을 부팅 시 잡는다. cluster·동시성은
22
+ * ioredis 가 단일 클라이언트로 pipelining/multiplexing 한다.
23
+ *
24
+ * # `db` = 별개 축 (connection 과 충돌 X)
25
+ * Redis 의 `db`(논리 DB 번호 0~15)는 connection(url/host)과 **별개 축**이라 url 과 충돌하지 않는다
26
+ * (Mongo 의 dbName 과 동일 취급, ADR-109). url path 에 `/2` 처럼 들어 있어도, 명시 `db` 가 우선한다.
27
+ *
28
+ * # 설정 (services.caches.<key>) — 통합 옵션 구조 (ADR-109/110)
29
+ * ```js
30
+ * services: {
31
+ * caches: {
32
+ * main: {
33
+ * driver: 'redis',
34
+ * // (a) 연결 — url 또는 discrete (host/port/user/password)
35
+ * url: 'redis://:pw@host:6379/1', // 또는 ↓
36
+ * host: 'localhost', port: 6379, user: 'default', password: '...',
37
+ * // db — 논리 DB 번호 (connection 과 별개 축)
38
+ * db: 1,
39
+ * // (b) options — ioredis passthrough (pool 은 미지원)
40
+ * options: { keyPrefix: 'app:', commandTimeout: 5000, keepAlive: 30000,
41
+ * tls: {}, retryStrategy: (n) => Math.min(n * 50, 2000) },
42
+ * },
43
+ * },
44
+ * }
45
+ * ```
46
+ * `url`(또는 deprecated 별칭 `connectionString`) **XOR** discrete(host/port/user/password). `pool`
47
+ * 지정 시 throw(ADR-110). 비밀번호/url 은 healthCheck/getStats/에러 details 에 절대 노출하지 않는다.
48
+ *
49
+ * @module adapters/redis-adapter
50
+ */
51
+ import { MegaValidationError } from '../errors/http-errors.js'
52
+ import { MegaCacheAdapter } from './mega-cache-adapter.js'
53
+ import { resolveConnection, assertPlainObject } from './adapter-options.js'
54
+ import * as Registry from './registry.js'
55
+
56
+ /**
57
+ * @typedef {object} RedisConfig
58
+ * @property {string} [driver] - 'redis' (매니저가 사용 — 어댑터는 무시).
59
+ * @property {string} [url] - `redis://[:pw@]host:port[/db]` 연결 문자열 (discrete 와 배타).
60
+ * @property {string} [connectionString] - `url` 의 deprecated 별칭 (하위 호환).
61
+ * @property {string} [host] @property {number} [port] @property {string} [user] @property {string} [password]
62
+ * @property {number} [db] - 논리 DB 번호 0~15 (connection 과 별개 축, url path 보다 우선).
63
+ * @property {any} [pool] - 미지원 — 지정 시 `adapter.invalid_option` throw (Redis 는 풀 모델 아님, ADR-110).
64
+ * @property {Record<string, any>} [options] - ioredis passthrough (keyPrefix, commandTimeout, tls, retryStrategy, keepAlive, …).
65
+ */
66
+
67
+ /**
68
+ * Redis URL 에서 db path(`redis://host:port/<db>`)만 제거해 반환한다. 명시 `db` 가
69
+ * url path db 를 이기게 하려고, url 이 db 를 주장하지 않도록 pathname 을 비운다. `new URL` 이
70
+ * 비밀번호/특수문자를 안전하게 보존·재직렬화한다(실측 round-trip 확인). 파싱 실패(잘못된 url)면 원본을
71
+ * 그대로 반환 — db 옵션은 clientOptions 에 이미 set 돼 있어 best-effort 로 적용되고, 잘못된 url 의 실제
72
+ * 연결 실패는 `_connect` 의 ioredis 가 surfacing 한다(여기서 throw 하면 url 의 시크릿이 노출될 수 있어 X).
73
+ * @param {string} url @returns {string}
74
+ */
75
+ function stripUrlDb(url) {
76
+ let u
77
+ try {
78
+ u = new URL(url)
79
+ } catch {
80
+ // 잘못된 url 형식 — 가공하지 않고 원본 반환(db 는 옵션으로 best-effort). 시크릿 노출 방지로 throw X.
81
+ return url
82
+ }
83
+ u.pathname = ''
84
+ return u.toString()
85
+ }
86
+
87
+ export class MegaRedisAdapter extends MegaCacheAdapter {
88
+ /** @type {import('ioredis').Redis | null} 연결된 ioredis 클라이언트 (connect 후에만). */
89
+ #client = null
90
+ /** @type {string | undefined} 연결 URL (시크릿 포함 — 외부 노출 금지). discrete 모드면 undefined. */
91
+ #url
92
+ /** @type {import('ioredis').RedisOptions} _connect 에서 `new Redis()` 에 넘길 옵션(생성자에서 고정). */
93
+ #clientOptions
94
+ /** @type {number | undefined} 논리 DB 번호 (getStats 노출용). */
95
+ #db
96
+
97
+ /**
98
+ * @param {RedisConfig} [config] - services.caches.<key> 설정.
99
+ * @throws {MegaValidationError} `adapter.connection_required` - url·discrete 둘 다 없음.
100
+ * @throws {MegaValidationError} `adapter.connection_conflict` - url + discrete 동시 지정.
101
+ * @throws {MegaValidationError} `adapter.invalid_option` - pool 지정/옵션 타입 오류/db 범위 오류.
102
+ */
103
+ constructor(config = /** @type {any} */ ({})) {
104
+ super(config)
105
+
106
+ // Redis 는 풀 모델이 아니므로 pool 지정을 명시 거부(silent 무시 X).
107
+ if (config.pool !== undefined) {
108
+ throw new MegaValidationError(
109
+ 'adapter.invalid_option',
110
+ 'redis: connection pooling is not supported (Redis uses a single multiplexed connection + optional cluster). Remove "pool"; tune concurrency via ioredis "options" instead.',
111
+ { details: { driver: 'redis', option: 'pool' } },
112
+ )
113
+ }
114
+
115
+ // 연결 모드(url XOR discrete) 결정. db 는 connection 과 별개 축이라 url 과 충돌하지 않음(ADR-109/110).
116
+ const conn = resolveConnection(config, { driver: 'redis', dbConflictsWithUrl: false })
117
+
118
+ // db 번호 검증 — 0~15 정수(Redis 기본 databases 16개). 미지정이면 옵션에서 생략(ioredis 디폴트 0).
119
+ if (config.db !== undefined) {
120
+ if (!Number.isInteger(config.db) || config.db < 0 || config.db > 15) {
121
+ throw new MegaValidationError('adapter.invalid_option', 'redis "db" must be an integer between 0 and 15.', {
122
+ details: { driver: 'redis', option: 'db', value: config.db },
123
+ })
124
+ }
125
+ this.#db = config.db
126
+ }
127
+
128
+ assertPlainObject('options', config.options, { driver: 'redis' })
129
+
130
+ /** @type {import('ioredis').RedisOptions} */
131
+ const clientOptions = {}
132
+ if (config.options !== undefined) Object.assign(clientOptions, config.options)
133
+ // 라이프사이클은 베이스 상태 머신이 관리 — ioredis 자동 연결을 끄고 _connect() 에서 명시 연결한다.
134
+ // (사용자가 options 로 lazyConnect:false 를 줘도 우리 계약이 이긴다 — 마지막에 덮어쓴다.)
135
+ clientOptions.lazyConnect = true
136
+
137
+ if (conn.url === undefined) {
138
+ // discrete 모드 — host/port/username/password/db 를 옵션으로. ioredis 의 ACL 사용자 키는 `username`.
139
+ if (conn.host !== undefined) clientOptions.host = conn.host
140
+ if (conn.port !== undefined) clientOptions.port = conn.port
141
+ if (conn.user !== undefined) clientOptions.username = conn.user
142
+ if (conn.password !== undefined) clientOptions.password = conn.password
143
+ }
144
+ // db 는 url/discrete 무관 — 명시 지정 시 옵션으로 강제(url path 의 db 보다 우선, ADR-110).
145
+ if (this.#db !== undefined) clientOptions.db = this.#db
146
+
147
+ // ⚠️ 근본원인: ioredis `parseOptions` 는 인자를 순서대로 lodash `defaults`(=빈 키만
148
+ // 채움, **먼저 온 값이 이김**)로 병합한다(node_modules/ioredis/built/Redis.js, 실 소스 확인).
149
+ // `new Redis(url, options)` 에선 url path 의 db(`redis://…/1`)가 먼저 채워져 명시 `options.db` 가
150
+ // **무시**된다(ioredis 5.11 실측). ADR-110 의 "명시 db 우선" 계약을 지키려고, 명시 db 가 있으면
151
+ // url 에서 db path 를 제거해 url 이 db 를 주장하지 않게 한다(타입-안전: `new Redis(url, options)` 순서 유지).
152
+ this.#url = this.#db !== undefined && conn.url !== undefined ? stripUrlDb(conn.url) : conn.url
153
+ this.#clientOptions = clientOptions
154
+ }
155
+
156
+ /**
157
+ * `ioredis` 클라이언트 생성 + `connect()` + `ping()` 으로 실제 연결 검증.
158
+ * driver 는 **connect 시점에 lazy import** — 모듈 import(배럴 경유 자기등록)만으로는 ioredis 를
159
+ * 로드하지 않아, 본 어댑터를 안 쓰는 환경이 ioredis 설치를 강제받지 않는다(DB 어댑터 정합).
160
+ *
161
+ * @protected
162
+ * @returns {Promise<void>}
163
+ */
164
+ async _connect() {
165
+ const { Redis } = await import('ioredis')
166
+ // url 모드면 `new Redis(url, options)`, discrete 모드면 `new Redis(options)`. lazyConnect 라
167
+ // 생성자에서 연결을 시작하지 않으므로 아래 connect() 가 유일한 연결 시점이다. (명시 db 가 url path
168
+ // db 를 이기게 하는 처리는 생성자에서 url 의 db path 를 제거하는 방식으로 했다 — #stripUrlDb 참조.)
169
+ const client = this.#url !== undefined ? new Redis(this.#url, this.#clientOptions) : new Redis(this.#clientOptions)
170
+ try {
171
+ await client.connect()
172
+ // connect 직후 실제 ping 으로 응답·인증 검증 — 잘못된 자격증명/호스트를 부팅 시 잡는다(fail-fast).
173
+ const pong = await client.ping()
174
+ if (pong !== 'PONG') {
175
+ throw new MegaValidationError('adapter.health_failed', `redis ping returned unexpected reply: ${pong}`, {
176
+ details: { driver: 'redis', reply: pong },
177
+ })
178
+ }
179
+ this.#client = client
180
+ } catch (err) {
181
+ // 검증 실패 시 leak 방지를 위해 client 강제 종료 후 원본 에러 전파.
182
+ try {
183
+ client.disconnect()
184
+ } catch (closeErr) {
185
+ // 검증 실패 후 정리(disconnect) 실패는 비치명적 — 원본 연결 에러가 진짜 원인.
186
+ console.warn('[MegaRedisAdapter] client.disconnect() failed after connect validation error (original error wins):', closeErr)
187
+ }
188
+ throw err
189
+ }
190
+ }
191
+
192
+ /**
193
+ * ioredis 클라이언트 graceful 종료(`quit` — 진행 중 명령 flush 후 닫음).
194
+ * 베이스 상태 머신이 connected 상태에서만 호출을 보장한다.
195
+ * @protected
196
+ * @returns {Promise<void>}
197
+ */
198
+ async _disconnect() {
199
+ if (this.#client !== null) {
200
+ const client = this.#client
201
+ this.#client = null
202
+ await client.quit()
203
+ }
204
+ }
205
+
206
+ /**
207
+ * raw ioredis handle (ADR-009). `connect()` 후에만 베이스 `native` getter 가 호출한다.
208
+ * @protected
209
+ * @returns {import('ioredis').Redis}
210
+ */
211
+ _native() {
212
+ if (this.#client === null) {
213
+ // 베이스 native getter 가 state 검증을 먼저 하므로 정상 경로에선 도달 안 함 — 방어.
214
+ return this._notImplemented('native')
215
+ }
216
+ return this.#client
217
+ }
218
+
219
+ /**
220
+ * 헬스 체크 — 실제 `ping` 으로 응답성 확인 (베이스 디폴트는 상태만 반영).
221
+ * 실패는 throw 없이 `ok:false` + 사유(베이스 계약). 비밀번호/url 은 노출하지 않는다.
222
+ *
223
+ * @returns {Promise<{ ok: boolean, driver: 'redis', state: string, db?: number, error?: string }>}
224
+ */
225
+ async healthCheck() {
226
+ if (this.state !== 'connected' || this.#client === null) {
227
+ return { ok: false, driver: 'redis', state: this.state }
228
+ }
229
+ try {
230
+ const pong = await this.#client.ping()
231
+ return { ok: pong === 'PONG', driver: 'redis', state: this.state, db: this.#db }
232
+ } catch (err) {
233
+ return {
234
+ ok: false,
235
+ driver: 'redis',
236
+ state: this.state,
237
+ db: this.#db,
238
+ error: err instanceof Error ? err.message : String(err),
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * 누적 통계 + redis 특화(driver/db/연결 status). 연결 status 는 ioredis 의 connection 상태 문자열
245
+ * ('wait'|'connecting'|'connect'|'ready'|'close'|'end' 등) — 풀이 없어 이걸 노출한다.
246
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, db: number | undefined, status: string | undefined }}
247
+ */
248
+ getStats() {
249
+ return {
250
+ ...super.getStats(),
251
+ driver: 'redis',
252
+ db: this.#db,
253
+ // ioredis 인스턴스의 연결 상태 문자열(미연결이면 undefined).
254
+ status: this.#client?.status,
255
+ }
256
+ }
257
+
258
+ // ──────────────────────────────────────────────────────────────────────
259
+ // MegaCacheAdapter 인터페이스 — get / set / del / has (JSON 직렬화 + TTL)
260
+ // hook + 상태검증 + stats 는 `_instrument` 가 처리(ADR-077).
261
+ // ──────────────────────────────────────────────────────────────────────
262
+
263
+ /**
264
+ * 키 조회. 없으면 `null`(throw X, 베이스 계약). 저장된 JSON 을 파싱해 원래 값으로 복원한다.
265
+ * @param {string} key
266
+ * @returns {Promise<any>}
267
+ */
268
+ async get(key) {
269
+ return this._instrument('get', { key }, async () => {
270
+ const raw = await /** @type {import('ioredis').Redis} */ (this.#client).get(key)
271
+ if (raw === null) return null // miss
272
+ return JSON.parse(raw)
273
+ })
274
+ }
275
+
276
+ /**
277
+ * 키 저장. `ttl`(초) 지정 시 `SET key val EX ttl`(SETEX 의미), 없으면 `SET key val`(무한).
278
+ * 값은 JSON 직렬화 — 직렬화 불가(undefined/함수/심볼)면 `cache.unserializable` throw(silent 저장 X).
279
+ *
280
+ * @param {string} key
281
+ * @param {any} value
282
+ * @param {{ ttl?: number }} [opts] - `ttl` 초 단위 양의 정수(베이스 `_assertTtl` 검증).
283
+ * @returns {Promise<void>}
284
+ */
285
+ async set(key, value, { ttl } = {}) {
286
+ // TTL·직렬화 검증은 의도적으로 `_instrument` **밖**에서 한다(L-1 결정): 잘못된 인자는 네트워크
287
+ // I/O·hook·stats 누적 이전에 fail-fast 로 거부하는 게 맞다(인자 오류는 어댑터 "호출"이 아니라
288
+ // 프로그래밍 오류 — instrumented 호출 통계에 섞이면 안 됨). 정상 경로의 실제 I/O 만 _instrument 가 감싼다.
289
+ this._assertTtl(ttl)
290
+ const raw = JSON.stringify(value)
291
+ if (raw === undefined) {
292
+ // JSON.stringify(undefined/함수/심볼) === undefined — 저장 시 silent 손상 대신 명시 거부.
293
+ throw new MegaValidationError('cache.unserializable', `redis set("${key}"): value is not JSON-serializable (undefined/function/symbol).`, {
294
+ details: { key, type: typeof value },
295
+ })
296
+ }
297
+ return this._instrument('set', { key, ttl }, async () => {
298
+ const client = /** @type {import('ioredis').Redis} */ (this.#client)
299
+ // ttl 있으면 EX(초) 동반 — ioredis 가 SETEX 와 동등하게 atomic 처리. 없으면 무한.
300
+ if (ttl !== undefined) await client.set(key, raw, 'EX', ttl)
301
+ else await client.set(key, raw)
302
+ })
303
+ }
304
+
305
+ /**
306
+ * 키 삭제. 없는 키 삭제는 에러 아님(idempotent — DEL 은 0 반환).
307
+ * @param {string} key
308
+ * @returns {Promise<void>}
309
+ */
310
+ async del(key) {
311
+ return this._instrument('del', { key }, async () => {
312
+ await /** @type {import('ioredis').Redis} */ (this.#client).del(key)
313
+ })
314
+ }
315
+
316
+ /**
317
+ * 키 존재 여부 (Boolean — `has*`, ADR-036). `EXISTS` 는 존재 개수(0/1)를 반환.
318
+ * @param {string} key
319
+ * @returns {Promise<boolean>}
320
+ */
321
+ async has(key) {
322
+ return this._instrument('has', { key }, async () => {
323
+ const n = await /** @type {import('ioredis').Redis} */ (this.#client).exists(key)
324
+ return n === 1
325
+ })
326
+ }
327
+ }
328
+
329
+ // 빌트인 driver 자기등록 (ADR-044) — 배럴(`adapters/index.js`)이 본 모듈을 import 하면 트리거된다.
330
+ // ioredis 는 _connect() 의 lazy import 까지 로드되지 않으므로 등록은 안전(미사용 환경 비강제).
331
+ Registry.register('redis', MegaRedisAdapter)