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,350 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaFileAdapter — 로컬 파일시스템 캐시 어댑터 (Node `fs`/`path` 만, ADR-082/111).
4
+ *
5
+ * `MegaCacheAdapter` 의 두 번째 구체(Redis 다음). dev / 단일 인스턴스 / Redis 없는 환경에서
6
+ * 같은 `get/set/del/has` API 를 로컬 파일로 만족시킨다 — driver 키 한 줄만 바꿔 환경 전환(ADR-082).
7
+ * **신규 의존성 0** (`node:fs/promises`/`node:path`/`node:crypto` 표준만).
8
+ *
9
+ * # 표준 표면 (MegaCacheAdapter 상속)
10
+ * - `_connect()` — `fs.mkdir(basePath, { recursive: true })` 로 디렉토리 보장.
11
+ * - `_disconnect()`— no-op (파일시스템은 close 개념 없음).
12
+ * - `_native()` — `{ basePath }` (raw "디렉토리 핸들" 개념, ADR-009).
13
+ * - `healthCheck()`— basePath 에 probe 파일 write→read→unlink 로 실제 읽기/쓰기 가능 확인.
14
+ * - `getStats()` — 베이스 stats + file 특화(basePath/serializer/extension).
15
+ * - `get/set/del/has` — 단일 JSON envelope 파일 + TTL 메타데이터(아래).
16
+ *
17
+ * # 저장 포맷 — 단일 envelope 파일 (`.meta` 사이드카 X, ADR-111 이 ADR-082 스케치를 정제)
18
+ * ADR-082 초안은 값 파일 + 별도 `<key>.meta` TTL 파일 2개였으나, 두 파일이 desync 되면(한쪽만
19
+ * 써지거나 지워지면) 만료 판정이 깨진다. 본 구현은 **값과 만료시각을 한 파일의 JSON envelope**
20
+ * `{ v:1, key, value, expiresAt }` 로 묶어 그 위험을 제거한다(더 안전한 단일-파일 대안).
21
+ * `expiresAt` 은 epoch ms(무한이면 null). 쓰기는 **temp 파일 write 후 `rename`** 으로 atomic —
22
+ * 동시 write 가 반쯤 써진 파일을 읽는 일이 없다(POSIX rename 은 같은 파일시스템에서 원자적).
23
+ *
24
+ * # 파일명 — key 의 SHA-256 hex (경로 안전)
25
+ * 캐시 key 는 `mega:cache:<app>:<key>`(ADR-064) 처럼 `:` `/` 등 임의 문자를 포함할 수 있어 그대로
26
+ * 파일명에 못 쓴다(경로 탈출·길이 초과 위험). key 를 SHA-256 hex(64자)로 해싱해 파일명으로 쓰고,
27
+ * 원본 key 는 envelope 안에 보관(디버깅용). 해시라 충돌 가능성은 무시 가능하고 경로 탈출이 불가능.
28
+ *
29
+ * # serializer 옵션
30
+ * - `'json'`(디폴트): `value` 는 임의 JSON 직렬화 가능 값. envelope 의 `value` 에 그대로 담겨 round-trip.
31
+ * - `'raw'`: `value` 는 **문자열만** 허용 — 추가 JSON 인코딩 없이 불투명 문자열로 저장/복원.
32
+ * 두 모드 모두 파일은 JSON envelope 라 TTL 메타데이터가 일관되게 동작한다.
33
+ *
34
+ * # 미지원 옵션 (명시 — silent no-op 금지)
35
+ * `gracefulErrors`(에러 삼킴 금지), `concurrencyLimit`(atomic rename 이 동시쓰기 안전성을 이미
36
+ * 보장) 은 채택하지 않는다. 알 수 없는 옵션은 `adapter.invalid_option` throw(fail-fast) —
37
+ * File 은 옵션을 흡수할 하위 드라이버가 없어 오타가 조용히 무시되면 안 된다.
38
+ *
39
+ * # 설정 (services.caches.<key>) — ADR-111
40
+ * ```js
41
+ * services: {
42
+ * caches: {
43
+ * local: {
44
+ * driver: 'file',
45
+ * basePath: '/var/cache/mega', // 필수 (별칭 dir 도 허용, ADR-082 정합)
46
+ * options: { serializer: 'json', extension: '.json' },
47
+ * },
48
+ * },
49
+ * }
50
+ * ```
51
+ *
52
+ * @module adapters/file-adapter
53
+ */
54
+ import { mkdir, writeFile, readFile, rename, unlink } from 'node:fs/promises'
55
+ import { join } from 'node:path'
56
+ import { createHash, randomUUID } from 'node:crypto'
57
+ import { MegaValidationError } from '../errors/http-errors.js'
58
+ import { MegaCacheAdapter } from './mega-cache-adapter.js'
59
+ import * as Registry from './registry.js'
60
+
61
+ /** envelope 스키마 버전 — 포맷 변경 시 마이그레이션 분기용. */
62
+ const ENVELOPE_VERSION = 1
63
+
64
+ /** 허용 options 키 (그 외는 fail-fast throw). */
65
+ const KNOWN_OPTIONS = new Set(['serializer', 'extension'])
66
+
67
+ /**
68
+ * @typedef {object} FileConfig
69
+ * @property {string} [driver] - 'file' (매니저가 사용 — 어댑터는 무시).
70
+ * @property {string} [basePath] - 캐시 파일 저장 디렉토리 (필수).
71
+ * @property {string} [dir] - `basePath` 의 별칭 (ADR-082 정합, 하위 호환).
72
+ * @property {{ serializer?: 'json' | 'raw', extension?: string }} [options]
73
+ */
74
+
75
+ /**
76
+ * @typedef {object} CacheEnvelope - 디스크에 저장되는 단일 파일 포맷.
77
+ * @property {number} v - 스키마 버전.
78
+ * @property {string} key - 원본 캐시 key (디버깅용 — 파일명은 해시라 역추적 불가).
79
+ * @property {any} value - 저장 값(serializer='raw' 면 문자열).
80
+ * @property {number | null} expiresAt - 만료 epoch ms (무한이면 null).
81
+ */
82
+
83
+ export class MegaFileAdapter extends MegaCacheAdapter {
84
+ /** @type {string} 캐시 디렉토리 절대/상대 경로. */
85
+ #basePath
86
+ /** @type {'json' | 'raw'} */
87
+ #serializer
88
+ /** @type {string} 파일 확장자 (예 '.json'). */
89
+ #extension
90
+
91
+ /**
92
+ * @param {FileConfig} [config] - services.caches.<key> 설정.
93
+ * @throws {MegaValidationError} `adapter.basepath_required` - basePath/dir 누락.
94
+ * @throws {MegaValidationError} `adapter.invalid_option` - 옵션 타입/미지원 키 오류.
95
+ */
96
+ constructor(config = /** @type {any} */ ({})) {
97
+ super(config)
98
+
99
+ // basePath 필수 (dir 별칭 허용 — ADR-082 가 'dir' 로 스케치했으므로 하위 호환).
100
+ const basePath = config.basePath ?? config.dir
101
+ if (typeof basePath !== 'string' || basePath.length === 0) {
102
+ throw new MegaValidationError(
103
+ 'adapter.basepath_required',
104
+ 'file: "basePath" (or alias "dir") is required — the directory to store cache files (e.g. "/var/cache/mega").',
105
+ { details: { driver: 'file', basePath: basePath ?? null } },
106
+ )
107
+ }
108
+ this.#basePath = basePath
109
+
110
+ const options = config.options ?? {}
111
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
112
+ throw new MegaValidationError('adapter.invalid_option', 'file "options" must be a plain object.', {
113
+ details: { driver: 'file', type: Array.isArray(options) ? 'array' : options === null ? 'null' : typeof options },
114
+ })
115
+ }
116
+ // 알 수 없는 옵션 fail-fast — File 은 옵션을 흡수할 하위 드라이버가 없다.
117
+ for (const k of Object.keys(options)) {
118
+ if (!KNOWN_OPTIONS.has(k)) {
119
+ throw new MegaValidationError('adapter.invalid_option', `file: unknown option "${k}". Known: ${[...KNOWN_OPTIONS].join(', ')}.`, {
120
+ details: { driver: 'file', option: k },
121
+ })
122
+ }
123
+ }
124
+
125
+ const serializer = options.serializer ?? 'json'
126
+ if (serializer !== 'json' && serializer !== 'raw') {
127
+ throw new MegaValidationError('adapter.invalid_option', `file "serializer" must be 'json' or 'raw' (got "${serializer}").`, {
128
+ details: { driver: 'file', option: 'serializer', value: serializer },
129
+ })
130
+ }
131
+ this.#serializer = serializer
132
+
133
+ const extension = options.extension ?? '.json'
134
+ if (typeof extension !== 'string' || extension.length === 0) {
135
+ throw new MegaValidationError('adapter.invalid_option', 'file "extension" must be a non-empty string (e.g. ".json").', {
136
+ details: { driver: 'file', option: 'extension', value: extension },
137
+ })
138
+ }
139
+ this.#extension = extension
140
+ }
141
+
142
+ /**
143
+ * 캐시 디렉토리 보장 (`mkdir -p`). 이미 있으면 no-op.
144
+ * @protected
145
+ * @returns {Promise<void>}
146
+ */
147
+ async _connect() {
148
+ await mkdir(this.#basePath, { recursive: true })
149
+ }
150
+
151
+ /**
152
+ * no-op — 파일시스템은 연결/해제 개념이 없다(베이스 디폴트와 동일하지만 의도를 명시).
153
+ * @protected
154
+ * @returns {Promise<void>}
155
+ */
156
+ async _disconnect() {}
157
+
158
+ /**
159
+ * raw "디렉토리 핸들" (ADR-009). 파일 어댑터는 native driver 가 없어 basePath 정보를 노출한다.
160
+ * @protected
161
+ * @returns {{ basePath: string }}
162
+ */
163
+ _native() {
164
+ return { basePath: this.#basePath }
165
+ }
166
+
167
+ /**
168
+ * 헬스 체크 — basePath 에 probe 파일을 write→read→unlink 해 실제 읽기/쓰기 가능 확인
169
+ * (베이스 디폴트는 상태만 반영). 실패는 throw 없이 `ok:false` + 사유(베이스 계약).
170
+ *
171
+ * @returns {Promise<{ ok: boolean, driver: 'file', state: string, basePath?: string, error?: string }>}
172
+ */
173
+ async healthCheck() {
174
+ if (this.state !== 'connected') {
175
+ return { ok: false, driver: 'file', state: this.state }
176
+ }
177
+ const probe = join(this.#basePath, `.health-${randomUUID()}`)
178
+ try {
179
+ await writeFile(probe, 'ok', 'utf8')
180
+ const back = await readFile(probe, 'utf8')
181
+ await unlink(probe)
182
+ return { ok: back === 'ok', driver: 'file', state: this.state, basePath: this.#basePath }
183
+ } catch (err) {
184
+ // probe 실패 시 잔여 파일 정리(존재하면) — 정리 실패는 비치명적.
185
+ await unlink(probe).catch(() => {})
186
+ return {
187
+ ok: false,
188
+ driver: 'file',
189
+ state: this.state,
190
+ basePath: this.#basePath,
191
+ error: err instanceof Error ? err.message : String(err),
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 누적 통계 + file 특화(basePath/serializer/extension).
198
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, basePath: string, serializer: string, extension: string }}
199
+ */
200
+ getStats() {
201
+ return {
202
+ ...super.getStats(),
203
+ driver: 'file',
204
+ basePath: this.#basePath,
205
+ serializer: this.#serializer,
206
+ extension: this.#extension,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * 캐시 key → 디스크 파일 경로. key 를 SHA-256 hex 로 해싱해 경로 안전 파일명으로 만든다(모듈 docstring).
212
+ * @param {string} key @returns {string}
213
+ */
214
+ #pathFor(key) {
215
+ const hashed = createHash('sha256').update(key, 'utf8').digest('hex')
216
+ return join(this.#basePath, hashed + this.#extension)
217
+ }
218
+
219
+ /**
220
+ * envelope 를 atomic 하게 기록 — temp 파일에 쓴 뒤 `rename` 으로 교체(반쯤 써진 파일 노출 방지).
221
+ * temp 는 같은 디렉토리에 둬야 rename 이 같은 파일시스템 내에서 원자적으로 동작한다.
222
+ * @param {string} finalPath @param {CacheEnvelope} envelope @returns {Promise<void>}
223
+ */
224
+ async #atomicWrite(finalPath, envelope) {
225
+ const tmp = `${finalPath}.tmp-${randomUUID()}`
226
+ try {
227
+ await writeFile(tmp, JSON.stringify(envelope), 'utf8')
228
+ await rename(tmp, finalPath)
229
+ } catch (err) {
230
+ // rename 전 실패 시 temp 잔여 정리 — 정리 실패는 원본 에러를 가리지 않게 격리.
231
+ await unlink(tmp).catch(() => {})
232
+ throw err
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 키 조회. 없거나 만료면 `null`(만료 파일은 lazy 삭제). serializer='raw' 면 문자열로 복원.
238
+ * @param {string} key
239
+ * @returns {Promise<any>}
240
+ */
241
+ async get(key) {
242
+ return this._instrument('get', { key }, async () => {
243
+ const path = this.#pathFor(key)
244
+ let raw
245
+ try {
246
+ raw = await readFile(path, 'utf8')
247
+ } catch (err) {
248
+ // 파일 없음(ENOENT) = cache miss(정상). 그 외 I/O 에러는 진짜 실패라 전파(무차별 삼킴 X).
249
+ if (/** @type {any} */ (err)?.code === 'ENOENT') return null
250
+ throw err
251
+ }
252
+ /** @type {CacheEnvelope} */
253
+ const env = JSON.parse(raw)
254
+ if (env.expiresAt !== null && Date.now() > env.expiresAt) {
255
+ // 만료 — lazy 삭제 후 miss. 삭제 실패는 비치명적(다음 set/get 이 재시도).
256
+ await unlink(path).catch(() => {})
257
+ return null
258
+ }
259
+ return env.value
260
+ })
261
+ }
262
+
263
+ /**
264
+ * 키 저장. `ttl`(초) 지정 시 `expiresAt = now + ttl*1000`, 없으면 무한(null).
265
+ * serializer='raw' 면 value 는 문자열이어야 한다(아니면 `cache.unserializable`).
266
+ *
267
+ * @param {string} key
268
+ * @param {any} value
269
+ * @param {{ ttl?: number }} [opts] - `ttl` 초 단위 양의 정수(베이스 `_assertTtl` 검증).
270
+ * @returns {Promise<void>}
271
+ */
272
+ async set(key, value, { ttl } = {}) {
273
+ // TTL·직렬화 검증은 의도적으로 `_instrument` **밖**(fail-fast). 잘못된 인자는 디스크 I/O·hook·stats
274
+ // 이전에 거부 — 프로그래밍 오류를 instrumented 호출 통계에 섞지 않는다(L-1, redis 어댑터와 동일 결정).
275
+ this._assertTtl(ttl)
276
+ if (this.#serializer === 'raw' && typeof value !== 'string') {
277
+ throw new MegaValidationError('cache.unserializable', `file set("${key}"): serializer='raw' requires a string value (got ${typeof value}).`, {
278
+ details: { key, type: typeof value, serializer: 'raw' },
279
+ })
280
+ }
281
+ if (this.#serializer === 'json') {
282
+ // JSON 모드 — 직렬화 불가 값(undefined/함수/심볼)은 명시 거부(silent 손상 X).
283
+ const probe = JSON.stringify(value)
284
+ if (probe === undefined) {
285
+ throw new MegaValidationError('cache.unserializable', `file set("${key}"): value is not JSON-serializable (undefined/function/symbol).`, {
286
+ details: { key, type: typeof value },
287
+ })
288
+ }
289
+ }
290
+ return this._instrument('set', { key, ttl }, async () => {
291
+ /** @type {CacheEnvelope} */
292
+ const envelope = {
293
+ v: ENVELOPE_VERSION,
294
+ key,
295
+ value,
296
+ expiresAt: ttl !== undefined ? Date.now() + ttl * 1000 : null,
297
+ }
298
+ await this.#atomicWrite(this.#pathFor(key), envelope)
299
+ })
300
+ }
301
+
302
+ /**
303
+ * 키 삭제. 없는 키 삭제는 에러 아님(idempotent — ENOENT 흡수).
304
+ * @param {string} key
305
+ * @returns {Promise<void>}
306
+ */
307
+ async del(key) {
308
+ return this._instrument('del', { key }, async () => {
309
+ try {
310
+ await unlink(this.#pathFor(key))
311
+ } catch (err) {
312
+ // ENOENT = 이미 없음(del idempotent — 정상). 그 외 I/O 에러는 전파(무차별 삼킴 X).
313
+ if (/** @type {any} */ (err)?.code !== 'ENOENT') throw err
314
+ }
315
+ })
316
+ }
317
+
318
+ /**
319
+ * 키 존재 여부 (Boolean — `has*`, ADR-036). 만료된 키는 없는 것으로 취급(+lazy 삭제).
320
+ *
321
+ * `get()` 과 동일하게 **단일 readFile** 로 읽고 ENOENT 를 miss(=false)로 흡수한다(L-3). 이전엔
322
+ * `access()`+`readFile()` 2회 I/O 였는데, 그 사이 파일이 사라지는 TOCTOU 레이스 + 불필요한
323
+ * 이중 I/O 가 있었다 — get 패턴으로 통일해 둘 다 제거.
324
+ * @param {string} key
325
+ * @returns {Promise<boolean>}
326
+ */
327
+ async has(key) {
328
+ return this._instrument('has', { key }, async () => {
329
+ const path = this.#pathFor(key)
330
+ let raw
331
+ try {
332
+ raw = await readFile(path, 'utf8')
333
+ } catch (err) {
334
+ // 파일 없음(ENOENT) = 없음(false). 그 외 I/O 에러는 전파(무차별 삼킴 X).
335
+ if (/** @type {any} */ (err)?.code === 'ENOENT') return false
336
+ throw err
337
+ }
338
+ // 존재하더라도 만료면 false — get 과 동일 의미 유지(+lazy 삭제).
339
+ const env = /** @type {CacheEnvelope} */ (JSON.parse(raw))
340
+ if (env.expiresAt !== null && Date.now() > env.expiresAt) {
341
+ await unlink(path).catch(() => {})
342
+ return false
343
+ }
344
+ return true
345
+ })
346
+ }
347
+ }
348
+
349
+ // 빌트인 driver 자기등록 (ADR-044) — 신규 의존성 0이라 import 부작용 없음.
350
+ Registry.register('file', MegaFileAdapter)