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,145 @@
1
+ // @ts-check
2
+ import { readFile, readdir } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { Marked } from 'marked'
6
+ import hljs from 'highlight.js'
7
+ import { MegaService } from 'mega-framework'
8
+ import { MegaNotFoundError } from 'mega-framework/errors'
9
+
10
+ /**
11
+ * GuideService — /guide 가이드 뷰어 로직(서버사이드 마크다운 렌더). 파일명 `guide-service.js` → 자동 DI
12
+ * 이름 `guide`(ctx.services.guide, ADR-148). 레포 루트의 `docs/guide/*.md` 를 읽어,
13
+ *
14
+ * 1) **목록** — 디렉토리를 scan 해 각 파일의 첫 H1 을 제목으로 뽑는다(인덱스 카드용).
15
+ * 2) **단일 페이지** — 마크다운을 marked 로 HTML 화하고, 코드블록은 highlight.js 로 미리 하이라이트한다.
16
+ * 브라우저 JS 를 추가하지 않으려 렌더를 전부 서버에서 끝낸다(CSP script-src 'self' 호환).
17
+ *
18
+ * 가이드 파일은 정적이라 목록은 프로세스 단위로 캐시한다(개발 중 변경은 재시작으로 갱신).
19
+ */
20
+
21
+ /** 가이드 마크다운 디렉토리 — 레포 루트의 docs/guide(이 파일 기준 5단계 위). */
22
+ const GUIDE_DIR = fileURLToPath(new URL('../../../../../docs/guide/', import.meta.url))
23
+
24
+ /** 유효 slug 형식 — 소문자·숫자·하이픈만(경로 조작 차단의 1차 게이트). */
25
+ const SLUG_RE = /^[a-z0-9-]+$/
26
+
27
+ /**
28
+ * GitHub 호환 헤더 슬러그에서 제거할 문자 집합.
29
+ * 출처: github-slugger(https://github.com/Flet/github-slugger) — 구두점을 지우고 공백을 하이픈으로 바꾸며
30
+ * 유니코드(한글 포함)는 보존한다. docs/guide 의 목차 앵커(`#mega-g--generate` 등)가 이 규칙으로 만들어져 있어
31
+ * 같은 규칙으로 헤딩 id 를 달아야 페이지 내 목차 링크가 동작한다.
32
+ */
33
+ const SLUG_STRIP = /[ -⸀-⹿\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~’]/g
34
+
35
+ /** 목록 캐시(프로세스 단위). 가이드 파일은 정적이므로 1회 scan 후 재사용. */
36
+ let listCache = null
37
+
38
+ /**
39
+ * 마크다운 첫 H1 을 제목으로 뽑는다. 없으면 null.
40
+ * @param {string} md
41
+ * @returns {string | null}
42
+ */
43
+ function parseTitle(md) {
44
+ const m = md.match(/^#\s+(.+?)\s*$/m)
45
+ return m ? m[1].trim() : null
46
+ }
47
+
48
+ /** HTML 특수문자 이스케이프(하이라이터를 안 거치는 코드블록 fallback 용). @param {string} s @returns {string} */
49
+ function escapeHtml(s) {
50
+ return s
51
+ .replace(/&/g, '&amp;')
52
+ .replace(/</g, '&lt;')
53
+ .replace(/>/g, '&gt;')
54
+ .replace(/"/g, '&quot;')
55
+ }
56
+
57
+ /**
58
+ * 헤딩 id 생성기 — github-slugger 와 같은 규칙. 같은 문서 내 중복 slug 에는 `-1`, `-2` … 를 붙여 유일화한다.
59
+ * (예: 명령마다 반복되는 `### 시그니처`.) 페이지마다 새로 만들어 카운터를 격리한다.
60
+ * @returns {(raw: string) => string}
61
+ */
62
+ function makeSlugger() {
63
+ /** @type {Record<string, number>} base slug → 지금까지 본 횟수. */
64
+ const occurrences = Object.create(null)
65
+ return (raw) => {
66
+ const base = raw.toLowerCase().replace(SLUG_STRIP, '').replace(/ /g, '-')
67
+ let result = base
68
+ while (occurrences[result] !== undefined) {
69
+ occurrences[base] = (occurrences[base] ?? 0) + 1
70
+ result = `${base}-${occurrences[base]}`
71
+ }
72
+ occurrences[result] = 0
73
+ return result
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 가이드 1페이지 전용 marked 인스턴스. 코드블록은 highlight.js 로 미리 하이라이트하고, 헤딩에는 목차 앵커용
79
+ * id 를 단다. 지원하지 않는 언어(ejs 등)는 하이라이트 없이 이스케이프만 한다(코드가 깨지지 않게).
80
+ * @returns {Marked}
81
+ */
82
+ function createMarked() {
83
+ const slugger = makeSlugger()
84
+ const m = new Marked({ gfm: true, breaks: true })
85
+ m.use({
86
+ renderer: {
87
+ /** @this {any} @param {{ text: string, lang?: string }} token */
88
+ code({ text, lang }) {
89
+ const language = lang && hljs.getLanguage(lang) ? lang : null
90
+ const body = language ? hljs.highlight(text, { language }).value : escapeHtml(text)
91
+ const cls = language ? ` language-${language}` : ''
92
+ return `<pre><code class="hljs${cls}">${body}</code></pre>\n`
93
+ },
94
+ /** @this {any} @param {{ tokens: any[], depth: number, text: string }} token */
95
+ heading({ tokens, depth, text }) {
96
+ const inner = this.parser.parseInline(tokens)
97
+ const id = slugger(text)
98
+ return `<h${depth} id="${id}">${inner}</h${depth}>\n`
99
+ },
100
+ },
101
+ })
102
+ return m
103
+ }
104
+
105
+ export class GuideService extends MegaService {
106
+ /**
107
+ * docs/guide 의 모든 가이드를 slug·title 로 나열한다(파일명 오름차순 = 가이드 번호순). 프로세스 단위 캐시.
108
+ * @returns {Promise<Array<{ slug: string, title: string }>>}
109
+ */
110
+ async listGuides() {
111
+ if (listCache) return listCache
112
+ const files = (await readdir(GUIDE_DIR)).filter((f) => f.endsWith('.md')).sort()
113
+ const guides = []
114
+ for (const file of files) {
115
+ const slug = file.slice(0, -3)
116
+ if (!SLUG_RE.test(slug)) continue
117
+ const content = await readFile(join(GUIDE_DIR, file), 'utf8')
118
+ guides.push({ slug, title: parseTitle(content) ?? slug })
119
+ }
120
+ this.ctx.log?.debug?.({ count: guides.length }, 'guide.list')
121
+ listCache = guides
122
+ return guides
123
+ }
124
+
125
+ /**
126
+ * 단일 가이드를 HTML 로 렌더한다. slug 는 목록에 실제로 존재하는 것만 허용한다(화이트리스트 — 경로 조작 차단).
127
+ * @param {string} slug
128
+ * @returns {Promise<{ slug: string, title: string, html: string }>}
129
+ * @throws {MegaNotFoundError} slug 가 형식에 안 맞거나 목록에 없을 때.
130
+ */
131
+ async renderGuide(slug) {
132
+ if (!SLUG_RE.test(slug)) {
133
+ throw new MegaNotFoundError('guide.not_found', `Guide '${slug}' not found.`)
134
+ }
135
+ const guides = await this.listGuides()
136
+ const found = guides.find((g) => g.slug === slug)
137
+ if (!found) {
138
+ throw new MegaNotFoundError('guide.not_found', `Guide '${slug}' not found.`)
139
+ }
140
+ const md = await readFile(join(GUIDE_DIR, `${slug}.md`), 'utf8')
141
+ const html = String(createMarked().parse(md))
142
+ this.ctx.log?.debug?.({ slug, bytes: html.length }, 'guide.render')
143
+ return { slug: found.slug, title: found.title, html }
144
+ }
145
+ }
@@ -0,0 +1,83 @@
1
+ // @ts-check
2
+ import { MegaService, MegaJobQueue } from 'mega-framework'
3
+ import { EmailJob } from '../jobs/email-job.js'
4
+
5
+ /** JetStream NOT_FOUND API 에러 코드(스트림/메시지 없음) — mega-job-queue 의 동일 상수와 정합. */
6
+ const NOT_FOUND_CODE = '404'
7
+
8
+ /**
9
+ * EmailJob 의 DLQ 스트림/서브젝트 이름. MegaJobQueue 는 `<subject>.dlq` 로 발행하고 DLQ 스트림을
10
+ * `<streamPrefix>_<sanitized subject>_DLQ`(streamPrefix 기본 'MEGA_JOBS', subject 의 '.'→'_')로 만든다
11
+ * (mega-job-queue `#dlqStreamName`/`#routeToDlq` 정본). 웹은 이 스트림을 직접 읽어 격리된 잡을 보여준다.
12
+ */
13
+ const DLQ_SUBJECT = `${EmailJob.subject}.dlq`
14
+ const DLQ_STREAM = `MEGA_JOBS_${EmailJob.subject.replace(/[^A-Za-z0-9_-]/g, '_')}_DLQ`
15
+
16
+ /**
17
+ * JobsDemoService — /demo/jobs 잡 큐 데모 로직(ADR-028/119). 파일명 `jobs-demo-service.js` → 자동 DI 이름
18
+ * `jobsDemo`(ctx.services.jobsDemo, ADR-148).
19
+ *
20
+ * producer 측(웹 mega start 프로세스)에서 EmailJob 을 NATS JetStream 에 enqueue 한다 — nc 는 `ctx.bus('jobs')`
21
+ * 어댑터의 raw 핸들(`.native`, ADR-009)로 풀고, MegaJobQueue 가 워크 스트림 발행을 담당한다. 소비·재시도·DLQ
22
+ * 라우팅은 `mega worker` 프로세스의 EmailJob.run 이 처리하며, 양쪽이 같은 'demo' 캐시(redis)·같은 NATS 스트림을
23
+ * 보므로 enqueue 결과·처리 이벤트·DLQ 격리분이 한 화면에 함께 보인다.
24
+ */
25
+ export class JobsDemoService extends MegaService {
26
+ /**
27
+ * EmailJob 1건을 큐에 넣는다(JetStream publish — 서버에 영속 저장 후 워커가 가져감).
28
+ * @param {{ id: string, to: string, subject?: string, mode: 'ok'|'flaky'|'fail' }} payload
29
+ * @returns {Promise<{ seq: number, stream: string, duplicate: boolean }>}
30
+ */
31
+ async enqueue(payload) {
32
+ const nc = this.ctx.bus('jobs').native
33
+ const queue = new MegaJobQueue({ nc })
34
+ const ack = await queue.enqueue(EmailJob, payload)
35
+ this.ctx.log?.debug?.({ id: payload.id, mode: payload.mode, seq: ack.seq }, 'jobs-demo.enqueue')
36
+ return ack
37
+ }
38
+
39
+ /**
40
+ * 최근 처리 이벤트(최신순) — 워커가 EmailJob.run 에서 남긴 시도/성공/실패 타임라인.
41
+ * @returns {Promise<Array<{ id: string, to: string, mode: string, attempt: number, status: string, at: string }>>}
42
+ */
43
+ async events() {
44
+ const redis = this.ctx.cache('demo').native
45
+ const raw = await redis.lrange(EmailJob.EVENTS_KEY, 0, EmailJob.EVENTS_MAX - 1)
46
+ return raw.map((/** @type {string} */ s) => JSON.parse(s))
47
+ }
48
+
49
+ /**
50
+ * DLQ(Dead Letter Queue) 상태 — 영구 실패해 격리된 잡 수 + 가장 최근 1건. DLQ 스트림이 아직 없으면
51
+ * (DLQ 로 간 잡이 한 번도 없으면) 빈 상태를 돌려준다.
52
+ * @returns {Promise<{ count: number, latest: { failedAt: string, deliveryCount: number, error: string, payload: any } | null }>}
53
+ */
54
+ async dlq() {
55
+ const nc = this.ctx.bus('jobs').native
56
+ const jsm = await nc.jetstreamManager()
57
+ let count = 0
58
+ try {
59
+ const info = await jsm.streams.info(DLQ_STREAM)
60
+ count = info.state.messages
61
+ } catch (e) {
62
+ // 스트림 미존재(404)는 "DLQ 로 간 잡이 아직 없음" — 빈 상태로 본다. 그 외(연결 장애 등)는 전파.
63
+ if (/** @type {any} */ (e)?.code !== NOT_FOUND_CODE) throw e
64
+ return { count: 0, latest: null }
65
+ }
66
+ if (count === 0) return { count, latest: null }
67
+ let latest = null
68
+ try {
69
+ const msg = await jsm.streams.getMessage(DLQ_STREAM, { last_by_subj: DLQ_SUBJECT })
70
+ const body = /** @type {any} */ (msg.json())
71
+ latest = {
72
+ failedAt: body.failedAt,
73
+ deliveryCount: body.deliveryCount,
74
+ error: body.error?.message ?? String(body.error ?? ''),
75
+ payload: body.payload,
76
+ }
77
+ } catch (e) {
78
+ // 카운트는 있으나 마지막 메시지 조회가 404(경쟁 삭제 등)면 latest 없이 카운트만 보여준다. 그 외는 전파.
79
+ if (/** @type {any} */ (e)?.code !== NOT_FOUND_CODE) throw e
80
+ }
81
+ return { count, latest }
82
+ }
83
+ }
@@ -0,0 +1,59 @@
1
+ // @ts-check
2
+ import { MegaService, MegaTracing } from 'mega-framework'
3
+
4
+ /**
5
+ * LogsDemoService — /demo/logs 구조적 로깅 데모 로직(ADR-023/141/163). 파일명 `logs-demo-service.js` →
6
+ * 자동 DI 이름 `logsDemo`(ctx.services.logsDemo, ADR-148).
7
+ *
8
+ * `ctx.log`(요청별 pino child, reqId 바인딩)로 실제 로그를 emit 한다 — console sink 로 나가며, redact 설정이
9
+ * `password`/`token`/`secret` 을 `[Redacted]` 로 마스킹하고, trace_id mixin 이 활성 span 의 trace_id 를 자동
10
+ * 첨부한다(ADR-141). 브라우저는 console 출력을 볼 수 없으므로, emit 한 로그의 **안전한 메타데이터만**(level/msg/
11
+ * trace_id/reqId/at — 시크릿 제외) redis LIST 에 남겨 화면에서 emit·trace 상관을 확인하게 한다. 키는 'demo'
12
+ * 캐시 안 `demo:logs:*` 네임스페이스다. 실제 마스킹·구조적 NDJSON 은 서버 콘솔에서 확인한다.
13
+ */
14
+ export class LogsDemoService extends MegaService {
15
+ /** 최근 emit 메타 LIST 키. */
16
+ static RECENT_KEY = 'demo:logs:recent'
17
+ /** 이력 보관 최대 건수(LTRIM 유지). */
18
+ static RECENT_MAX = 20
19
+ /** emit 허용 레벨 화이트리스트(임의 메서드 호출 방지). */
20
+ static LEVELS = ['debug', 'info', 'warn', 'error']
21
+
22
+ /**
23
+ * 한 건의 로그를 실제 pino logger 로 emit 하고, 안전 메타데이터를 redis 이력에 남긴다. payload 엔
24
+ * 시연용 민감필드(token/password)를 일부러 넣어 console 에서 마스킹을 확인하게 한다 — 단 redis 이력엔
25
+ * 시크릿을 저장하지 않는다.
26
+ * @param {string} level - 'debug'|'info'|'warn'|'error'(화이트리스트). 그 외는 'info' 폴백.
27
+ * @param {string} message - 사용자 입력 메시지(표시·로그 msg).
28
+ * @returns {Promise<{ level: string, message: string, traceId: string|null, at: string }>}
29
+ */
30
+ async emit(level, message) {
31
+ const lvl = LogsDemoService.LEVELS.includes(level) ? level : 'info'
32
+ const msg = typeof message === 'string' && message.trim().length > 0 ? message.trim() : 'demo log line'
33
+ const ids = MegaTracing.currentTraceIds()
34
+
35
+ // 실제 emit — 민감필드(demo.token/demo.password)는 redact 가 console 출력에서 [Redacted] 로 가린다(ADR-141).
36
+ this.ctx.log?.[lvl]?.(
37
+ { demo: { token: 'demo-token-abc123', password: 'p@ssw0rd' }, sample: true, kind: 'logs-demo' },
38
+ msg,
39
+ )
40
+
41
+ // 화면 표시용 안전 메타데이터(시크릿 제외)만 redis 이력에 보관.
42
+ const redis = this.ctx.cache('demo').native
43
+ const record = { level: lvl, message: msg, traceId: ids?.traceId ?? null, reqId: this.ctx.requestId ?? null, at: new Date().toISOString() }
44
+ await redis.lpush(LogsDemoService.RECENT_KEY, JSON.stringify(record))
45
+ await redis.ltrim(LogsDemoService.RECENT_KEY, 0, LogsDemoService.RECENT_MAX - 1)
46
+ return { level: lvl, message: msg, traceId: ids?.traceId ?? null, at: record.at }
47
+ }
48
+
49
+ /**
50
+ * 화면 렌더용 스냅샷 — 최근 emit 메타 목록 + 허용 레벨.
51
+ * @returns {Promise<{ recent: Array<{ level: string, message: string, traceId: string|null, reqId: string|null, at: string }>, levels: string[] }>}
52
+ */
53
+ async snapshot() {
54
+ const redis = this.ctx.cache('demo').native
55
+ const rawList = await redis.lrange(LogsDemoService.RECENT_KEY, 0, LogsDemoService.RECENT_MAX - 1)
56
+ const recent = rawList.map((/** @type {string} */ s) => JSON.parse(s))
57
+ return { recent, levels: LogsDemoService.LEVELS }
58
+ }
59
+ }
@@ -0,0 +1,144 @@
1
+ // @ts-check
2
+ import { MegaService, collectCluster } from 'mega-framework'
3
+
4
+ /**
5
+ * MetricsDemoService — /demo/metrics 관측 데모 로직(ADR-131/132/154/163). 파일명 `metrics-demo-service.js` →
6
+ * 자동 DI 이름 `metricsDemo`(ctx.services.metricsDemo, ADR-148).
7
+ *
8
+ * 프레임워크 Prometheus 메트릭을 `collectCluster()`로 읽어 사람이 보기 좋은 숫자로 요약한다. raw `/metrics`
9
+ * 는 기계용(scrape)이라, 같은 데이터를 카드용으로 한 번 정리하는 게 데모의 목적이다. **클러스터 모드면
10
+ * `collectCluster` 가 마스터 IPC 로 전 워커 메트릭을 합산해 돌려준다(ADR-163)** — 단일 프로세스면 로컬과
11
+ * 동일. 덕분에 새로고침마다 다른 워커 숫자가 나오던 문제가 사라지고 클러스터 전체 누적이 일관되게 보인다.
12
+ *
13
+ * Prometheus exposition format(text/plain version=0.0.4)은 한 줄이 `name{labels} value` 다(주석은 `#` 시작).
14
+ * 이 포맷은 안정된 표준이라 curated 한 몇 개 메트릭 패밀리만 파싱해 합산/추출한다.
15
+ */
16
+ export class MetricsDemoService extends MegaService {
17
+ /** 라우트 카운트 카드에 보여줄 상위 라우트 개수. */
18
+ static TOP_ROUTES = 6
19
+
20
+ /**
21
+ * 한 줄(`name{a="1",b="2"} 3.5`)을 `{ name, labels, value }` 로 파싱한다. 주석/빈 줄/형식 불일치는 null.
22
+ * @param {string} line
23
+ * @returns {{ name: string, labels: Record<string,string>, value: number } | null}
24
+ */
25
+ static parseLine(line) {
26
+ const trimmed = line.trim()
27
+ if (trimmed.length === 0 || trimmed.startsWith('#')) return null
28
+ const m = trimmed.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{[^}]*\})?\s+([0-9eE.+-]+)$/)
29
+ if (!m) return null
30
+ const value = Number(m[3])
31
+ if (!Number.isFinite(value)) return null
32
+ /** @type {Record<string,string>} */
33
+ const labels = {}
34
+ if (m[2]) {
35
+ // {method="GET",route="/x"} → 각 key="value" 추출(값 안의 이스케이프 따옴표 허용).
36
+ const re = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g
37
+ let lm
38
+ while ((lm = re.exec(m[2])) !== null) labels[lm[1]] = lm[2].replace(/\\"/g, '"')
39
+ }
40
+ return { name: m[1], labels, value }
41
+ }
42
+
43
+ /**
44
+ * exposition 텍스트를 파싱된 샘플 배열로 변환한다.
45
+ * @param {string} text
46
+ * @returns {Array<{ name: string, labels: Record<string,string>, value: number }>}
47
+ */
48
+ static parse(text) {
49
+ /** @type {Array<{ name: string, labels: Record<string,string>, value: number }>} */
50
+ const samples = []
51
+ for (const line of text.split('\n')) {
52
+ const parsed = MetricsDemoService.parseLine(line)
53
+ if (parsed) samples.push(parsed)
54
+ }
55
+ return samples
56
+ }
57
+
58
+ /**
59
+ * 메트릭 패밀리(name)의 모든 label 조합 값을 합산한다(필터 옵션).
60
+ * @param {Array<{ name: string, labels: Record<string,string>, value: number }>} samples
61
+ * @param {string} name
62
+ * @param {(labels: Record<string,string>) => boolean} [filter]
63
+ * @returns {number}
64
+ */
65
+ static sum(samples, name, filter) {
66
+ let total = 0
67
+ for (const s of samples) {
68
+ if (s.name !== name) continue
69
+ if (filter && !filter(s.labels)) continue
70
+ total += s.value
71
+ }
72
+ return total
73
+ }
74
+
75
+ /**
76
+ * 화면 렌더용 스냅샷 — HTTP/잡/WS/process 핵심 카운터를 사람 친화 숫자로 정리한다. 메트릭 OFF 면
77
+ * `enabled:false`(collect() 가 빈 문자열) 로 알린다.
78
+ * @returns {Promise<{
79
+ * enabled: boolean,
80
+ * metricsPath: string,
81
+ * http: { total: number, byClass: Record<string,number>, topRoutes: Array<{ route: string, count: number }> },
82
+ * jobs: Record<string, number>,
83
+ * ws: { total: number, byType: Array<{ type: string, count: number }> },
84
+ * process: { heapUsedMb: number, rssMb: number, uptimeSec: number, cpuSec: number },
85
+ * }>}
86
+ */
87
+ async snapshot() {
88
+ const text = await collectCluster()
89
+ this.ctx.log?.debug?.({ enabled: text.length > 0, bytes: text.length }, 'metrics-demo.collect')
90
+ const samples = MetricsDemoService.parse(text)
91
+ const S = MetricsDemoService
92
+
93
+ // HTTP — 총 요청 수 + 상태 클래스(2xx/3xx/4xx/5xx) + 라우트별 상위.
94
+ const httpName = 'mega_http_requests_total'
95
+ /** @type {Record<string,number>} */
96
+ const byClass = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 }
97
+ /** @type {Record<string,number>} */
98
+ const routeCounts = {}
99
+ for (const s of samples) {
100
+ if (s.name !== httpName) continue
101
+ const cls = `${String(s.labels.status_code ?? '0')[0]}xx`
102
+ if (cls in byClass) byClass[cls] += s.value
103
+ const route = s.labels.route ?? '(unknown)'
104
+ routeCounts[route] = (routeCounts[route] ?? 0) + s.value
105
+ }
106
+ const topRoutes = Object.entries(routeCounts)
107
+ .map(([route, count]) => ({ route, count }))
108
+ .sort((a, b) => b.count - a.count)
109
+ .slice(0, S.TOP_ROUTES)
110
+
111
+ // 잡 — event(enqueued/processed/retried/dlq)별 합산(Step 2.1d 잡 큐와 연동).
112
+ const jobsName = 'mega_jobs_total'
113
+ /** @type {Record<string,number>} */
114
+ const jobs = { enqueued: 0, processed: 0, retried: 0, dlq: 0 }
115
+ for (const e of Object.keys(jobs)) jobs[e] = S.sum(samples, jobsName, (l) => l.event === e)
116
+
117
+ // WS — 총 메시지 수 + type 별(Step 2.1c 채팅과 연동).
118
+ const wsName = 'mega_ws_messages_total'
119
+ /** @type {Record<string,number>} */
120
+ const wsTypeCounts = {}
121
+ for (const s of samples) {
122
+ if (s.name !== wsName) continue
123
+ const type = s.labels.type ?? '(unknown)'
124
+ wsTypeCounts[type] = (wsTypeCounts[type] ?? 0) + s.value
125
+ }
126
+ const wsByType = Object.entries(wsTypeCounts)
127
+ .map(([type, count]) => ({ type, count }))
128
+ .sort((a, b) => b.count - a.count)
129
+
130
+ return {
131
+ enabled: text.length > 0,
132
+ metricsPath: '/metrics',
133
+ http: { total: S.sum(samples, httpName), byClass, topRoutes },
134
+ jobs,
135
+ ws: { total: S.sum(samples, wsName), byType: wsByType },
136
+ process: {
137
+ heapUsedMb: S.sum(samples, 'mega_process_memory_bytes', (l) => l.kind === 'heapUsed') / 1024 / 1024,
138
+ rssMb: S.sum(samples, 'mega_process_memory_bytes', (l) => l.kind === 'rss') / 1024 / 1024,
139
+ uptimeSec: S.sum(samples, 'mega_process_uptime_seconds'),
140
+ cpuSec: S.sum(samples, 'mega_process_cpu_seconds_total'),
141
+ },
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,75 @@
1
+ // @ts-check
2
+ import { MegaService } from 'mega-framework'
3
+ import { MegaNotFoundError, MegaValidationError } from 'mega-framework/errors'
4
+ import { Note } from '../models/note.js'
5
+
6
+ /**
7
+ * NoteService — note 도메인 비즈니스 로직(ADR-022). 컨트롤러는 모델을 직접 만지지 않고 이 서비스를 거친다.
8
+ * 파일명 `note-service.js` → 자동 DI 이름 `note`(ctx.services.note, ADR-148). 모델 import 는 서비스에서만 허용.
9
+ *
10
+ * mongo 백엔드라 unique 제약/충돌 매핑(postgres 23505)은 없다 — notes 데모는 제목·본문 검증만 한다.
11
+ */
12
+ export class NoteService extends MegaService {
13
+ /** 제목 최대 길이(과도한 입력 방어 — UI 카드 가독성 기준). */
14
+ static MAX_TITLE = 120
15
+
16
+ /** @returns {Promise<object[]>} */
17
+ async list() {
18
+ this.log.debug?.('note.list')
19
+ return Note.list()
20
+ }
21
+
22
+ /** @param {string} id @returns {Promise<object>} @throws {MegaNotFoundError} */
23
+ async get(id) {
24
+ const note = await Note.findById(String(id))
25
+ if (!note) throw new MegaNotFoundError('note.not_found', `Note ${id} not found`, { details: { id } })
26
+ return note
27
+ }
28
+
29
+ /**
30
+ * 생성 — 제목은 필수(공백 trim 후 비면 거부), 본문은 선택(빈 문자열 허용).
31
+ * @param {{ title?: unknown, body?: unknown }} input @returns {Promise<object>}
32
+ * @throws {MegaValidationError} `note.invalid` - 제목 누락 또는 길이 초과.
33
+ */
34
+ async create(input) {
35
+ const { title, body } = NoteService.#normalize(input)
36
+ this.log.debug?.({ title }, 'note.create')
37
+ return Note.create({ title, body })
38
+ }
39
+
40
+ /**
41
+ * 수정 — 검증 후 변경. 대상이 없으면 404.
42
+ * @param {string} id @param {{ title?: unknown, body?: unknown }} patch @returns {Promise<object>}
43
+ * @throws {MegaNotFoundError} @throws {MegaValidationError}
44
+ */
45
+ async update(id, patch) {
46
+ const { title, body } = NoteService.#normalize(patch)
47
+ const updated = await Note.update(String(id), { title, body })
48
+ if (!updated) throw new MegaNotFoundError('note.not_found', `Note ${id} not found`, { details: { id } })
49
+ return updated
50
+ }
51
+
52
+ /** @param {string} id @returns {Promise<{ deleted: true }>} @throws {MegaNotFoundError} */
53
+ async remove(id) {
54
+ const ok = await Note.remove(String(id))
55
+ if (!ok) throw new MegaNotFoundError('note.not_found', `Note ${id} not found`, { details: { id } })
56
+ return { deleted: true }
57
+ }
58
+
59
+ /**
60
+ * 입력 정규화·검증. 제목은 trim 후 필수·길이 제한, 본문은 trim 후 선택.
61
+ * @param {{ title?: unknown, body?: unknown }} input @returns {{ title: string, body: string }}
62
+ * @throws {MegaValidationError} `note.invalid`
63
+ */
64
+ static #normalize(input) {
65
+ const title = typeof input?.title === 'string' ? input.title.trim() : ''
66
+ const body = typeof input?.body === 'string' ? input.body.trim() : ''
67
+ if (!title) {
68
+ throw new MegaValidationError('note.invalid', 'title is required', { details: { title: false } })
69
+ }
70
+ if (title.length > NoteService.MAX_TITLE) {
71
+ throw new MegaValidationError('note.invalid', `title must be at most ${NoteService.MAX_TITLE} characters`, { details: { title: false } })
72
+ }
73
+ return { title, body }
74
+ }
75
+ }