mega-framework 0.1.6 → 0.1.8

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 (248) hide show
  1. package/README.md +9 -0
  2. package/bin/mega-ws-hub.js +2 -2
  3. package/package.json +33 -9
  4. package/sample/crud/.env +10 -1
  5. package/sample/crud/.env.example +10 -1
  6. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  7. package/sample/crud/.mega/journal/snapshot.json +261 -0
  8. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  9. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  10. package/sample/crud/apps/main/locales/server/en.json +12 -1
  11. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  12. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  13. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  14. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  15. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  16. package/sample/crud/apps/main/models/note-model.js +79 -0
  17. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  18. package/sample/crud/apps/main/models/user-model.js +146 -0
  19. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  20. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  21. package/sample/crud/apps/main/routes/users.js +55 -10
  22. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  23. package/sample/crud/apps/main/services/auth-service.js +39 -24
  24. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  25. package/sample/crud/apps/main/services/note-service.js +6 -6
  26. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  27. package/sample/crud/apps/main/services/user-service.js +62 -21
  28. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  29. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  30. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  31. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  32. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  33. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  34. package/sample/crud/mega.config.js +10 -2
  35. package/sample/crud/package.json +3 -3
  36. package/sample/crud/scripts/start-ws-hub.sh +20 -6
  37. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/adapter-manager.js +2 -1
  40. package/src/adapters/adapter-options.js +44 -3
  41. package/src/adapters/file-adapter.js +9 -5
  42. package/src/adapters/file-session-adapter.js +4 -3
  43. package/src/adapters/maria-adapter.js +33 -7
  44. package/src/adapters/mega-cache-adapter.js +83 -6
  45. package/src/adapters/mega-db-adapter.js +10 -1
  46. package/src/adapters/mongo-adapter.js +40 -8
  47. package/src/adapters/postgres-adapter.js +33 -6
  48. package/src/adapters/redis-adapter.js +7 -3
  49. package/src/adapters/sqlite-adapter.js +26 -3
  50. package/src/cli/commands/console-cmd.js +3 -1
  51. package/src/cli/commands/new.js +13 -3
  52. package/src/cli/commands/scaffold.js +173 -33
  53. package/src/cli/generators/index.js +140 -3
  54. package/src/cli/index.js +437 -155
  55. package/src/cli/watch.js +188 -0
  56. package/src/core/ajv-mapper.js +30 -3
  57. package/src/core/boot.js +464 -245
  58. package/src/core/cluster-metrics.js +13 -4
  59. package/src/core/ctx-builder.js +65 -3
  60. package/src/core/envelope.js +119 -12
  61. package/src/core/hub-link.js +89 -18
  62. package/src/core/i18n.js +11 -1
  63. package/src/core/index.js +7 -3
  64. package/src/core/mega-app.js +253 -505
  65. package/src/core/mega-cluster.js +4 -1
  66. package/src/core/mega-server.js +40 -9
  67. package/src/core/migration/dialect-registry.js +107 -0
  68. package/src/core/migration/dialects/README.md +62 -0
  69. package/src/core/migration/dialects/maria.js +496 -0
  70. package/src/core/migration/dialects/mongo.js +824 -0
  71. package/src/core/migration/dialects/postgres.js +563 -0
  72. package/src/core/migration/dialects/sqlite.js +476 -0
  73. package/src/core/migration/differ.js +456 -0
  74. package/src/core/migration/generate.js +508 -0
  75. package/src/core/migration/journal.js +167 -0
  76. package/src/core/migration/model-scan.js +84 -0
  77. package/src/core/migration/mongo-migration-db.js +97 -0
  78. package/src/core/migration/schema-builder.js +400 -0
  79. package/src/core/migration/schema-validator.js +315 -0
  80. package/src/core/migration-lock.js +205 -0
  81. package/src/core/migration-runner.js +166 -38
  82. package/src/core/multipart.js +28 -5
  83. package/src/core/pipeline.js +131 -0
  84. package/src/core/router.js +70 -65
  85. package/src/core/scope-registry.js +1 -0
  86. package/src/core/security.js +70 -12
  87. package/src/core/session-store.js +14 -1
  88. package/src/core/workers-manager.js +12 -1
  89. package/src/core/ws-cluster.js +10 -3
  90. package/src/core/ws-message.js +48 -4
  91. package/src/core/ws-presence.js +636 -0
  92. package/src/core/ws-roster.js +50 -8
  93. package/src/core/ws-upgrade.js +223 -12
  94. package/src/index.js +1 -1
  95. package/src/lib/hub-protocol.js +29 -0
  96. package/src/lib/mega-circuit-breaker.js +5 -3
  97. package/src/lib/mega-health.js +35 -4
  98. package/src/lib/mega-job-queue.js +151 -34
  99. package/src/lib/mega-job.js +37 -1
  100. package/src/lib/mega-metrics.js +31 -13
  101. package/src/lib/mega-plugin.js +34 -3
  102. package/src/lib/mega-schedule.js +40 -22
  103. package/src/lib/mega-shutdown.js +114 -39
  104. package/src/lib/mega-tracing.js +66 -19
  105. package/src/lib/mega-worker.js +33 -6
  106. package/src/lib/otel-resource.js +36 -0
  107. package/src/{cli → lib}/ws-hub.js +139 -15
  108. package/src/models/crud-sql-builder.js +133 -0
  109. package/src/models/mega-model.js +82 -2
  110. package/src/models/model-crud.js +483 -0
  111. package/src/models/mongo-crud.js +285 -0
  112. package/templates/adr/code.tpl +23 -0
  113. package/templates/model/code-mongo.tpl +35 -0
  114. package/templates/model/code.tpl +15 -1
  115. package/templates/model/test-mongo.tpl +38 -0
  116. package/templates/model/test.tpl +4 -0
  117. package/types/adapters/adapter-manager.d.ts +95 -0
  118. package/types/adapters/adapter-options.d.ts +93 -0
  119. package/types/adapters/file-adapter.d.ts +105 -0
  120. package/types/adapters/file-session-adapter.d.ts +103 -0
  121. package/types/adapters/index.d.ts +20 -0
  122. package/types/adapters/maria-adapter.d.ts +117 -0
  123. package/types/adapters/mega-adapter.d.ts +215 -0
  124. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  125. package/types/adapters/mega-cache-adapter.d.ts +73 -0
  126. package/types/adapters/mega-db-adapter.d.ts +50 -0
  127. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  128. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  129. package/types/adapters/mega-session-adapter.d.ts +32 -0
  130. package/types/adapters/mongo-adapter.d.ts +150 -0
  131. package/types/adapters/nats-adapter.d.ts +108 -0
  132. package/types/adapters/postgres-adapter.d.ts +141 -0
  133. package/types/adapters/redis-adapter.d.ts +78 -0
  134. package/types/adapters/redis-session-adapter.d.ts +82 -0
  135. package/types/adapters/redlock-adapter.d.ts +149 -0
  136. package/types/adapters/registry.d.ts +46 -0
  137. package/types/adapters/sqlite-adapter.d.ts +112 -0
  138. package/types/auth/index.d.ts +24 -0
  139. package/types/cli/commands/console-cmd.d.ts +37 -0
  140. package/types/cli/commands/new.d.ts +16 -0
  141. package/types/cli/commands/routes.d.ts +36 -0
  142. package/types/cli/commands/scaffold.d.ts +78 -0
  143. package/types/cli/commands/test-cmd.d.ts +14 -0
  144. package/types/cli/generators/index.d.ts +122 -0
  145. package/types/cli/index.d.ts +234 -0
  146. package/types/cli/template-engine.d.ts +40 -0
  147. package/types/cli/watch.d.ts +59 -0
  148. package/types/core/ajv-mapper.d.ts +27 -0
  149. package/types/core/boot.d.ts +233 -0
  150. package/types/core/cluster-metrics.d.ts +52 -0
  151. package/types/core/config-loader.d.ts +13 -0
  152. package/types/core/config-validator.d.ts +30 -0
  153. package/types/core/ctx-builder.d.ts +103 -0
  154. package/types/core/envelope.d.ts +79 -0
  155. package/types/core/error-mapper.d.ts +17 -0
  156. package/types/core/formbody.d.ts +41 -0
  157. package/types/core/hub-link.d.ts +266 -0
  158. package/types/core/i18n.d.ts +178 -0
  159. package/types/core/index.d.ts +28 -0
  160. package/types/core/mega-app.d.ts +529 -0
  161. package/types/core/mega-cluster.d.ts +104 -0
  162. package/types/core/mega-server.d.ts +91 -0
  163. package/types/core/mega-service.d.ts +31 -0
  164. package/types/core/migration/dialect-registry.d.ts +22 -0
  165. package/types/core/migration/dialects/maria.d.ts +99 -0
  166. package/types/core/migration/dialects/mongo.d.ts +89 -0
  167. package/types/core/migration/dialects/postgres.d.ts +117 -0
  168. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  169. package/types/core/migration/differ.d.ts +47 -0
  170. package/types/core/migration/generate.d.ts +56 -0
  171. package/types/core/migration/journal.d.ts +52 -0
  172. package/types/core/migration/model-scan.d.ts +19 -0
  173. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  174. package/types/core/migration/schema-builder.d.ts +197 -0
  175. package/types/core/migration/schema-validator.d.ts +20 -0
  176. package/types/core/migration-lock.d.ts +33 -0
  177. package/types/core/migration-runner.d.ts +101 -0
  178. package/types/core/multipart.d.ts +86 -0
  179. package/types/core/openapi.d.ts +62 -0
  180. package/types/core/pipeline.d.ts +93 -0
  181. package/types/core/router.d.ts +159 -0
  182. package/types/core/routes-loader.d.ts +21 -0
  183. package/types/core/scope-registry.d.ts +14 -0
  184. package/types/core/security.d.ts +77 -0
  185. package/types/core/services-loader.d.ts +27 -0
  186. package/types/core/session-cleanup-schedule.d.ts +19 -0
  187. package/types/core/session-store.d.ts +25 -0
  188. package/types/core/session.d.ts +77 -0
  189. package/types/core/static-assets.d.ts +73 -0
  190. package/types/core/template.d.ts +106 -0
  191. package/types/core/workers-manager.d.ts +79 -0
  192. package/types/core/ws-cluster.d.ts +208 -0
  193. package/types/core/ws-compression.d.ts +112 -0
  194. package/types/core/ws-controller.d.ts +65 -0
  195. package/types/core/ws-message.d.ts +106 -0
  196. package/types/core/ws-presence.d.ts +273 -0
  197. package/types/core/ws-roster.d.ts +108 -0
  198. package/types/core/ws-upgrade.d.ts +260 -0
  199. package/types/errors/config-error.d.ts +10 -0
  200. package/types/errors/http-errors.d.ts +120 -0
  201. package/types/errors/index.d.ts +3 -0
  202. package/types/errors/mega-error.d.ts +32 -0
  203. package/types/index.d.ts +39 -0
  204. package/types/lib/asp/config.d.ts +49 -0
  205. package/types/lib/asp/crypto.d.ts +43 -0
  206. package/types/lib/asp/errors.d.ts +30 -0
  207. package/types/lib/asp/nonce-cache.d.ts +52 -0
  208. package/types/lib/asp/plugin.d.ts +30 -0
  209. package/types/lib/asp/ws-terminator.d.ts +45 -0
  210. package/types/lib/env-mapper.d.ts +14 -0
  211. package/types/lib/hub-protocol.d.ts +106 -0
  212. package/types/lib/index.d.ts +22 -0
  213. package/types/lib/logger/telegram-core.d.ts +104 -0
  214. package/types/lib/logger/telegram-transport.d.ts +45 -0
  215. package/types/lib/mega-brute-force.d.ts +66 -0
  216. package/types/lib/mega-circuit-breaker.d.ts +243 -0
  217. package/types/lib/mega-cron.d.ts +66 -0
  218. package/types/lib/mega-hash.d.ts +32 -0
  219. package/types/lib/mega-health.d.ts +48 -0
  220. package/types/lib/mega-job-queue.d.ts +188 -0
  221. package/types/lib/mega-job-worker.d.ts +130 -0
  222. package/types/lib/mega-job.d.ts +145 -0
  223. package/types/lib/mega-logger.d.ts +45 -0
  224. package/types/lib/mega-metrics.d.ts +285 -0
  225. package/types/lib/mega-plugin.d.ts +245 -0
  226. package/types/lib/mega-retry.d.ts +85 -0
  227. package/types/lib/mega-schedule.d.ts +260 -0
  228. package/types/lib/mega-shutdown.d.ts +135 -0
  229. package/types/lib/mega-tracing.d.ts +224 -0
  230. package/types/lib/mega-worker.d.ts +129 -0
  231. package/types/lib/otel-resource.d.ts +16 -0
  232. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  233. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  234. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  235. package/types/lib/ws-hub.d.ts +259 -0
  236. package/types/models/crud-sql-builder.d.ts +48 -0
  237. package/types/models/index.d.ts +1 -0
  238. package/types/models/mega-model.d.ts +138 -0
  239. package/types/models/model-crud.d.ts +82 -0
  240. package/types/models/mongo-crud.d.ts +59 -0
  241. package/types/test/index.d.ts +84 -0
  242. package/.env +0 -127
  243. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  244. package/sample/crud/apps/main/models/note.js +0 -71
  245. package/sample/crud/apps/main/models/user.js +0 -86
  246. package/sample/crud/package-lock.json +0 -5665
  247. package/sample/crud/yarn.lock +0 -2142
  248. package/sample/simple/package-lock.json +0 -1851
@@ -1,19 +1,24 @@
1
1
  // @ts-check
2
2
  /**
3
- * 마이그레이션 러너 (ADR-149) — `apps/<app>/migrations/<ts>-<name>.js` 의 up/down 을
3
+ * 마이그레이션 러너 (ADR-149/184) — `apps/<app>/migrations/<ts>-<name>.js` 의 up/down 을
4
4
  * 대상 DB 에 적용·롤백하고, 적용 이력을 대상 DB 의 `mega_migrations` 테이블로 추적한다.
5
+ * 이력 행에는 파일 내용의 sha-256 `checksum` 을 함께 기록해, 적용 후 파일이 수정된 드리프트를
6
+ * `migrate:status`(modified)·`migrate`(경고 로그)에서 감지한다(ADR-190).
5
7
  *
6
8
  * 러너는 **어댑터에 비의존적**이다 — 호출자(`runMigrateHost`)가 연결된 DB 어댑터(`{ query, withTransaction }`)를
7
9
  * `db` 로 넘긴다. 마이그레이션 파일의 `up(db)/down(db)` 는 이 어댑터를 받아 SQL 을 실행한다. 어댑터가
8
10
  * `withTransaction` 을 지원하면 각 마이그레이션을 트랜잭션으로 감싸 부분 실패를 롤백한다(postgres DDL-in-tx).
11
+ * 동시 실행 락은 러너가 아니라 **호스트 레벨**({@link module:core/migration-lock}) 책임이다 —
12
+ * driver 별 락 메커니즘이 달라 어댑터 비의존성을 깨지 않기 위함(ADR-190).
9
13
  *
10
- * 부기 SQL(테이블 생성·SELECT·INSERT·DELETE)은 표준 SQL 만 쓰고, 값(마이그레이션 name·applied_at)은
11
- * **framework-controlled 라 따옴표가 불가능**(name 은 {@link MIGRATION_FILE_RE} 검증, applied_at 은 ISO)하므로
12
- * placeholder(`$1` vs `?`) dialect 분기 없이 인라인해도 인젝션-안전하다.
14
+ * 부기 SQL(테이블 생성·SELECT·INSERT·DELETE)은 표준 SQL 만 쓰고, 값(마이그레이션 name·applied_at·checksum)은
15
+ * **framework-controlled 라 따옴표가 불가능**(name 은 {@link MIGRATION_FILE_RE} 검증, applied_at 은 ISO,
16
+ * checksum 은 sha-256 hex)하므로 placeholder(`$1` vs `?`) dialect 분기 없이 인라인해도 인젝션-안전하다.
13
17
  *
14
18
  * @module core/migration-runner
15
19
  */
16
- import { readdirSync } from 'node:fs'
20
+ import { createHash } from 'node:crypto'
21
+ import { readdirSync, readFileSync } from 'node:fs'
17
22
  import { join, resolve as pathResolve } from 'node:path'
18
23
  import { pathToFileURL } from 'node:url'
19
24
  import { MegaConfigError } from '../errors/config-error.js'
@@ -62,9 +67,11 @@ export function collectMigrationFiles({ projectRoot, appNames }) {
62
67
  }
63
68
 
64
69
  /**
65
- * 마이그레이션 모듈 로드 + up/down 검증.
70
+ * 마이그레이션 모듈 로드 + up/down 검증. `export const transaction = false`(옵트아웃, ADR-205)면
71
+ * 러너가 트랜잭션 래핑을 끈다 — `CREATE INDEX CONCURRENTLY` 처럼 트랜잭션 안에서 실행 불가한
72
+ * 문장을 위한 escape hatch(부분 실패 시 롤백 없음은 파일 작성자 책임 — 생성기가 헤더에 명시).
66
73
  * @param {string} absPath
67
- * @returns {Promise<{ up: Function, down: Function }>}
74
+ * @returns {Promise<{ up: Function, down: Function, useTransaction: boolean }>}
68
75
  * @throws {MegaConfigError} import 실패 / up·down 누락.
69
76
  */
70
77
  export async function loadMigration(absPath) {
@@ -77,35 +84,55 @@ export async function loadMigration(absPath) {
77
84
  if (typeof mod.up !== 'function' || typeof mod.down !== 'function') {
78
85
  throw new MegaConfigError('migration.invalid', `Migration '${absPath}' must export async function up(db) and down(db).`)
79
86
  }
80
- return { up: mod.up, down: mod.down }
87
+ return { up: mod.up, down: mod.down, useTransaction: mod.transaction !== false }
81
88
  }
82
89
 
83
90
  /**
84
91
  * 이력 테이블 보장(idempotent). 표준 SQL — postgres/maria/sqlite 공통.
92
+ * v1(checksum 없는) 기존 테이블은 컬럼 부재를 감지해 ADD COLUMN 으로 보강한다(ADR-190) —
93
+ * `ADD COLUMN IF NOT EXISTS` 가 sqlite 미지원이라 SELECT 시도로 부재를 판별한다.
85
94
  * @param {MigrationDb} db
86
95
  * @returns {Promise<void>}
87
96
  */
88
97
  async function ensureTable(db) {
89
- await db.query(`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name VARCHAR(255) PRIMARY KEY, applied_at VARCHAR(64) NOT NULL)`)
98
+ await db.query(
99
+ `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name VARCHAR(255) PRIMARY KEY, applied_at VARCHAR(64) NOT NULL, checksum VARCHAR(64))`,
100
+ )
101
+ try {
102
+ await db.query(`SELECT checksum FROM ${MIGRATIONS_TABLE} LIMIT 1`)
103
+ } catch {
104
+ // checksum 컬럼 부재(v1 테이블) — 보강한다. SELECT 실패가 다른 원인이었다면 아래 ALTER 도
105
+ // 같은 원인으로 throw 하므로 에러가 묻히지 않는다(P4 — silent 무시 아님).
106
+ await db.query(`ALTER TABLE ${MIGRATIONS_TABLE} ADD COLUMN checksum VARCHAR(64)`)
107
+ }
90
108
  }
91
109
 
92
110
  /**
93
- * 적용된 마이그레이션 name 집합.
111
+ * 적용된 마이그레이션 이력 — name → checksum(checksum 도입 전 v1 행은 null).
94
112
  * @param {MigrationDb} db
95
- * @returns {Promise<Set<string>>}
113
+ * @returns {Promise<Map<string, string | null>>}
96
114
  */
97
- async function appliedSet(db) {
98
- const res = await db.query(`SELECT name FROM ${MIGRATIONS_TABLE}`)
115
+ async function appliedRecords(db) {
116
+ const res = await db.query(`SELECT name, checksum FROM ${MIGRATIONS_TABLE}`)
99
117
  const rows = res?.rows ?? (Array.isArray(res) ? res : [])
100
- return new Set(rows.map((/** @type {any} */ r) => r.name))
118
+ return new Map(rows.map((/** @type {any} */ r) => [r.name, r.checksum ?? null]))
101
119
  }
102
120
 
103
121
  /**
104
- * 적용 기록 INSERT. name(검증된 [\d a-z -])·iso(ISO)는 따옴표 불가라 인라인 안전(placeholder dialect 회피).
105
- * @param {MigrationDb} db @param {string} name @param {string} appliedAtIso
122
+ * 마이그레이션 파일 내용의 sha-256 hex digest 적용 파일 수정(드리프트) 감지용(ADR-190).
123
+ * @param {string} absPath @returns {string}
106
124
  */
107
- async function recordApplied(db, name, appliedAtIso) {
108
- await db.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ('${name}', '${appliedAtIso}')`)
125
+ function fileChecksum(absPath) {
126
+ return createHash('sha256').update(readFileSync(absPath)).digest('hex')
127
+ }
128
+
129
+ /**
130
+ * 적용 기록 INSERT. name(검증된 [\d a-z -])·iso(ISO)·checksum(sha-256 hex)은 따옴표 불가라 인라인
131
+ * 안전(placeholder dialect 회피).
132
+ * @param {MigrationDb} db @param {string} name @param {string} appliedAtIso @param {string} checksum
133
+ */
134
+ async function recordApplied(db, name, appliedAtIso, checksum) {
135
+ await db.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at, checksum) VALUES ('${name}', '${appliedAtIso}', '${checksum}')`)
109
136
  }
110
137
 
111
138
  /**
@@ -120,38 +147,111 @@ async function removeApplied(db, name) {
120
147
  * 어댑터가 트랜잭션을 지원하면 fn 을 트랜잭션으로 감싸 실행한다. postgres 는 AsyncLocalStorage 로 tx
121
148
  * 컨텍스트를 추적하므로 fn 안의 `db.query` 가 같은 트랜잭션 클라이언트로 라우팅된다(ADR-106).
122
149
  * @param {MigrationDb} db @param {() => Promise<void>} fn
150
+ * @param {{ info?: Function, warn?: Function, debug?: Function }} [log]
123
151
  */
124
- async function runInTransaction(db, fn) {
152
+ async function runInTransaction(db, fn, log) {
125
153
  if (typeof db.withTransaction === 'function') {
126
154
  await db.withTransaction(async () => {
127
155
  await fn()
128
156
  })
129
157
  } else {
158
+ // 트랜잭션을 기대(transaction 미선언 = 기본 true)했는데 어댑터가 미지원(mongo 등)이면
159
+ // 조용한 폴스루가 "tx 보호를 받는다" 는 오인을 만든다(ADR-210 L-4) — 명시 경고 1줄.
160
+ log?.warn?.('migrate: adapter has no withTransaction — running without transaction (rollback unavailable on failure)')
130
161
  await fn()
131
162
  }
132
163
  }
133
164
 
134
165
  /**
135
- * 미적용(pending) 마이그레이션을 타임스탬프 순으로 모두 적용한다. 마이그레이션의 up + 이력 기록을
136
- * 트랜잭션으로 묶어 원자적으로 처리한다.
166
+ * 적용된 파일 적용 내용이 수정된(드리프트) 마이그레이션 이름 목록 저장된 checksum 과 현재
167
+ * 파일 digest 불일치. checksum 이 null(v1 이력)이면 비교 불가라 제외한다(ADR-190).
168
+ * @param {MigrationFile[]} files @param {Map<string, string | null>} applied
169
+ * @returns {string[]}
170
+ */
171
+ function findModified(files, applied) {
172
+ return files
173
+ .filter((m) => {
174
+ const stored = applied.get(m.name)
175
+ return stored != null && stored !== fileChecksum(m.absPath)
176
+ })
177
+ .map((m) => m.name)
178
+ }
179
+
180
+ /**
181
+ * 미적용(pending) 마이그레이션을 타임스탬프 순으로 모두 적용한다. 각 마이그레이션의 up + 이력 기록
182
+ * (sha-256 checksum 포함, ADR-190)을 한 트랜잭션으로 묶어 원자적으로 처리한다. 이미 적용된 파일이
183
+ * 적용 후 수정됐으면(checksum 불일치) 경고 로그를 남긴다 — 실행된 스키마와 파일이 어긋난 상태.
137
184
  *
138
- * @param {{ db: MigrationDb, projectRoot: string, appNames: string[], now?: () => string, log?: { info?: Function } }} opts
185
+ * @param {{ db: MigrationDb, projectRoot: string, appNames: string[], now?: () => string, log?: { info?: Function, warn?: Function, debug?: Function } }} opts
139
186
  * - now: 적용 시각 ISO 문자열 공급자(테스트 주입용, 기본 `new Date().toISOString()`).
140
187
  * @returns {Promise<{ applied: string[] }>}
141
188
  */
142
189
  export async function migrateUp({ db, projectRoot, appNames, now = () => new Date().toISOString(), log }) {
143
190
  await ensureTable(db)
144
- const applied = await appliedSet(db)
145
- const pending = collectMigrationFiles({ projectRoot, appNames }).filter((m) => !applied.has(m.name))
191
+ const applied = await appliedRecords(db)
192
+ const all = collectMigrationFiles({ projectRoot, appNames })
193
+ for (const name of findModified(all, applied)) {
194
+ log?.warn?.({ migration: name }, 'migrate.up checksum mismatch — file modified after apply')
195
+ }
196
+ const pending = all.filter((m) => !applied.has(m.name))
146
197
  /** @type {string[]} */
147
198
  const done = []
148
199
  for (const m of pending) {
149
- const { up } = await loadMigration(m.absPath)
200
+ const { up, useTransaction } = await loadMigration(m.absPath)
201
+ const checksum = fileChecksum(m.absPath)
150
202
  const appliedAt = now()
151
- await runInTransaction(db, async () => {
203
+ let skipped = false
204
+ const apply = async () => {
205
+ // 적용 직전 이력 재확인 — pending 산출(시작 시 1회)과 실제 적용 사이에 동시 러너가 끼어들 수
206
+ // 있다. 락이 약한 dialect(sqlite — 파일 락은 read-check-apply 원자성을 안 줌)에서 패자가
207
+ // 같은 DDL 을 재실행해 오도성 에러(duplicate column 등)로 죽는 것을, 명시 skip 으로 바꾼다.
208
+ const recheck = await db.query(`SELECT name FROM ${MIGRATIONS_TABLE} WHERE name = '${m.name}'`)
209
+ const recheckRows = recheck?.rows ?? (Array.isArray(recheck) ? recheck : [])
210
+ if (recheckRows.length > 0) {
211
+ skipped = true
212
+ log?.warn?.({ migration: m.name }, 'migrate.up skipped — already applied by a concurrent runner')
213
+ return
214
+ }
152
215
  await up(db)
153
- await recordApplied(db, m.name, appliedAt)
154
- })
216
+ await recordApplied(db, m.name, appliedAt, checksum)
217
+ }
218
+ try {
219
+ if (useTransaction) {
220
+ await runInTransaction(db, apply, log)
221
+ } else {
222
+ // `export const transaction = false` 옵트아웃(ADR-205 — CONCURRENTLY 등) — 부분 실패 시
223
+ // 롤백되지 않음을 로그로 표면화한다(파일 헤더에도 명시됨).
224
+ log?.warn?.({ migration: m.name }, 'migrate.up no-transaction migration (rollback unavailable on partial failure)')
225
+ await apply()
226
+ }
227
+ } catch (err) {
228
+ // 실패 후 이력 재확인 — 이력 기록은 적용 성공의 최종 단계라, 이름이 이력에 있으면 동시
229
+ // 러너가 이미 적용을 완료한 것이다(같은 파일을 정확히 동시에 시작한 패자는 직전 재확인
230
+ // 창을 지나 DDL 충돌·이력 UNIQUE 로 죽는다 — 실측). 오도성 에러 대신 명시 skip(ADR-208 M-2).
231
+ let appliedByOther = false
232
+ try {
233
+ const after = await db.query(`SELECT name FROM ${MIGRATIONS_TABLE} WHERE name = '${m.name}'`)
234
+ const afterRows = after?.rows ?? (Array.isArray(after) ? after : [])
235
+ appliedByOther = afterRows.length > 0
236
+ } catch (recheckErr) {
237
+ // 재확인 자체가 실패(연결 단절 등)하면 원인 판별 불가 — 원본 에러를 그대로 보고한다.
238
+ log?.debug?.({ err: recheckErr, migration: m.name }, 'migrate.up post-failure history recheck failed')
239
+ }
240
+ if (appliedByOther) {
241
+ log?.warn?.({ migration: m.name }, 'migrate.up skipped — already applied by a concurrent runner')
242
+ continue
243
+ }
244
+ // 어느 파일에서 죽었는지 없이 driver 원문만 던지면 다단 적용에서 추적이 어렵다 — 파일 컨텍스트 wrap.
245
+ // no-tx 파일은 부분 적용이 남을 수 있다 — 멱등 렌더(maria IF EXISTS·mongo not-found 허용)
246
+ // 덕에 원인 제거 후 같은 파일 재실행이 1차 복구 수단임을 에러에 직접 안내한다(ADR-208/210).
247
+ const noTxHint = useTransaction ? '' : ' [no-transaction — 부분 적용이 남았을 수 있습니다. 원인 제거 후 재실행하면 멱등 문장은 건너뜁니다]'
248
+ throw new MegaConfigError(
249
+ 'migration.apply_failed',
250
+ `Migration '${m.name}' failed during up(): ${/** @type {any} */ (err).message}${noTxHint} (file: ${m.absPath})`,
251
+ { cause: err, details: { migration: m.name, file: m.absPath, direction: 'up' } },
252
+ )
253
+ }
254
+ if (skipped) continue
155
255
  log?.info?.({ migration: m.name }, 'migrate.up applied')
156
256
  done.push(m.name)
157
257
  }
@@ -160,37 +260,65 @@ export async function migrateUp({ db, projectRoot, appNames, now = () => new Dat
160
260
 
161
261
  /**
162
262
  * 가장 최근 적용된 마이그레이션 1개를 롤백한다(down + 이력 삭제, 한 트랜잭션). 적용분이 없으면 no-op.
163
- * 적용 이력에 있으나 파일이 사라진 항목은 down 을 실행할 수 없어 건너뛴다.
164
263
  *
165
- * @param {{ db: MigrationDb, projectRoot: string, appNames: string[], log?: { info?: Function } }} opts
264
+ * 이력상 최신 적용분의 **파일이 사라진 경우 fail-fast** 한다(`migration.history_file_missing`, ADR-190)
265
+ * 조용히 건너뛰면 더 오래된 마이그레이션이 롤백되어(순서 역전) 이력과 실제 스키마가 어긋난다.
266
+ *
267
+ * @param {{ db: MigrationDb, projectRoot: string, appNames: string[], log?: { info?: Function, warn?: Function } }} opts
166
268
  * @returns {Promise<{ rolledBack: string | null }>}
269
+ * @throws {MegaConfigError} `migration.history_file_missing` - 최신 적용 이력의 파일 부재.
167
270
  */
168
271
  export async function migrateDown({ db, projectRoot, appNames, log }) {
169
272
  await ensureTable(db)
170
- const applied = await appliedSet(db)
273
+ const applied = await appliedRecords(db)
274
+ if (applied.size === 0) return { rolledBack: null }
275
+ // 이력의 최신 적용분 — name 은 `<ts>-<kebab>` 라 사전식 정렬 = 시간순.
276
+ const newest = [...applied.keys()].sort()[applied.size - 1]
171
277
  const appliedFiles = collectMigrationFiles({ projectRoot, appNames }).filter((m) => applied.has(m.name))
172
278
  const last = appliedFiles[appliedFiles.length - 1]
173
- if (!last) return { rolledBack: null }
174
- const { down } = await loadMigration(last.absPath)
175
- await runInTransaction(db, async () => {
279
+ if (last === undefined || last.name !== newest) {
280
+ throw new MegaConfigError(
281
+ 'migration.history_file_missing',
282
+ `Cannot roll back: migration '${newest}' is recorded in ${MIGRATIONS_TABLE} but its file is missing under apps/*/migrations. ` +
283
+ 'Restore the file, or remove the history row manually after verifying the schema.',
284
+ { details: { newestApplied: newest, lastFileFound: last?.name ?? null } },
285
+ )
286
+ }
287
+ const { down, useTransaction } = await loadMigration(last.absPath)
288
+ const rollback = async () => {
176
289
  await down(db)
177
290
  await removeApplied(db, last.name)
178
- })
291
+ }
292
+ try {
293
+ if (useTransaction) {
294
+ await runInTransaction(db, rollback)
295
+ } else {
296
+ log?.warn?.({ migration: last.name }, 'migrate.down no-transaction migration (rollback unavailable on partial failure)')
297
+ await rollback()
298
+ }
299
+ } catch (err) {
300
+ throw new MegaConfigError(
301
+ 'migration.apply_failed',
302
+ `Migration '${last.name}' failed during down(): ${/** @type {any} */ (err).message} (file: ${last.absPath})`,
303
+ { cause: err, details: { migration: last.name, file: last.absPath, direction: 'down' } },
304
+ )
305
+ }
179
306
  log?.info?.({ migration: last.name }, 'migrate.down rolled back')
180
307
  return { rolledBack: last.name }
181
308
  }
182
309
 
183
310
  /**
184
- * 적용/미적용 마이그레이션 목록(타임스탬프 ).
311
+ * 적용/미적용/드리프트(적용 후 수정) 마이그레이션 목록(타임스탬프 순, ADR-190).
185
312
  * @param {{ db: MigrationDb, projectRoot: string, appNames: string[] }} opts
186
- * @returns {Promise<{ applied: string[], pending: string[] }>}
313
+ * @returns {Promise<{ applied: string[], pending: string[], modified: string[] }>}
187
314
  */
188
315
  export async function migrateStatus({ db, projectRoot, appNames }) {
189
316
  await ensureTable(db)
190
- const applied = await appliedSet(db)
317
+ const applied = await appliedRecords(db)
191
318
  const all = collectMigrationFiles({ projectRoot, appNames })
192
319
  return {
193
320
  applied: all.filter((m) => applied.has(m.name)).map((m) => m.name),
194
321
  pending: all.filter((m) => !applied.has(m.name)).map((m) => m.name),
322
+ modified: findModified(all, applied),
195
323
  }
196
324
  }
@@ -23,6 +23,8 @@
23
23
  * 플러그인 내장 기능이 아니라 본 모듈이 게이트로 강제 → 비허용 415(`MegaUnsupportedMediaTypeError`).
24
24
  * - **경로 탐색(path traversal) 차단**: 저장 시 `sanitizeFilename`(basename + 선행 점 제거)으로 파일명에서
25
25
  * 디렉터리 성분을 제거하고, 최종 경로가 대상 디렉터리 내부인지 한 번 더 검증(이중 방어).
26
+ * - **파일명 충돌 차단**: 저장 파일명은 `uniquifyFilename`(타임스탬프+랜덤 접미)으로 유일화 — 동일명
27
+ * 동시/연속 업로드의 silent 덮어쓰기·스트림 race 를 막는다. 표시명(meta.filename)은 살균 원본 유지.
26
28
  * - **MIME 스푸핑 한계**: 게이트는 클라가 선언한 `Content-Type`(part header) 기준이다. 매직바이트 스니핑은
27
29
  * 하지 않는다(스트림 1패스 비용 회피). 선언 MIME 위조 가능성은 문서화된 한계이며, 진짜 콘텐츠 검증이
28
30
  * 필요하면 핸들러에서 `toBuffer()` 후 별도 검사한다.
@@ -47,7 +49,8 @@ import { mkdir, unlink } from 'node:fs/promises'
47
49
  import { createWriteStream } from 'node:fs'
48
50
  import { stat } from 'node:fs/promises'
49
51
  import { pipeline } from 'node:stream/promises'
50
- import { basename, resolve, sep } from 'node:path'
52
+ import { randomBytes } from 'node:crypto'
53
+ import { basename, extname, resolve, sep } from 'node:path'
51
54
  import fastifyMultipart from '@fastify/multipart'
52
55
  import { MegaUnsupportedMediaTypeError, MegaPayloadTooLargeError } from '../errors/http-errors.js'
53
56
  import * as MegaTracing from '../lib/mega-tracing.js'
@@ -88,6 +91,23 @@ export function sanitizeFilename(name) {
88
91
  return cleaned.length > 0 ? cleaned : 'upload'
89
92
  }
90
93
 
94
+ /**
95
+ * 살균된 파일명에 타임스탬프+랜덤 접미를 붙여 저장 파일명을 유일화한다.
96
+ *
97
+ * 살균만으로는 **같은 이름의 동시/연속 업로드가 silent 덮어쓰기**된다(두 사용자가 'photo.jpg' 를 올리면
98
+ * 나중 것이 이김, 동시면 스트림 교차 가능). 저장 파일명에 `-<ts36>-<rand8hex>` 를 붙여 충돌을 막는다 —
99
+ * 반환 메타의 `filename`(표시명)은 살균 원본 그대로 유지하고 `savedAs`(실제 경로)만 유일명을 갖는다.
100
+ *
101
+ * @param {string} name - {@link sanitizeFilename} 을 통과한 파일명.
102
+ * @returns {string} 예: 'photo.jpg' → 'photo-mbz3k1x2-9f2ac01b.jpg'.
103
+ * @example uniquifyFilename('report.pdf') // 'report-<ts36>-<rand>.pdf'
104
+ */
105
+ export function uniquifyFilename(name) {
106
+ const ext = extname(name)
107
+ const stem = name.slice(0, name.length - ext.length)
108
+ return `${stem}-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}${ext}`
109
+ }
110
+
91
111
  /**
92
112
  * MIME 타입이 화이트리스트에 허용되는지 (Boolean — `is*`). 빈 목록/미지정이면 **전부 허용**(게이트 비활성).
93
113
  * 정확 매치 + `type/*` 와일드카드(예: `'image/*'` 는 `'image/png'` 허용)를 지원한다.
@@ -210,9 +230,10 @@ export function registerMultipart(fastify, { upload, appName = '(unknown)', logg
210
230
  }
211
231
 
212
232
  /**
213
- * 업로드된 모든 파일을 `destDir` 에 안전하게 저장한다(`req.saveUploads` 구현부). 파일명을 살균하고 최종
214
- * 경로가 대상 디렉터리 내부인지 검증한 뒤 스트리밍으로 디스크에 쓴다. `mega.upload` span + `mega_upload_*`
215
- * 메트릭을 기록한다. MIME 게이트는 래핑된 `req.files()` 가 적용한다(비허용 415 전파).
233
+ * 업로드된 모든 파일을 `destDir` 에 안전하게 저장한다(`req.saveUploads` 구현부). 파일명을 살균·유일화하고
234
+ * 최종 경로가 대상 디렉터리 내부인지 검증한 뒤 스트리밍으로 디스크에 쓴다. `mega.upload` span +
235
+ * `mega_upload_*` 메트릭을 기록한다. MIME 게이트는 래핑된 `req.files()` 가 적용한다(비허용 415 전파).
236
+ * 반환 meta 의 `filename` 은 살균 표시명, `savedAs` 는 유일화된 실제 저장 절대경로다.
216
237
  *
217
238
  * @param {object} args
218
239
  * @param {any} args.req - Fastify 요청(래핑된 `files()` 보유).
@@ -236,7 +257,9 @@ async function saveUploads({ req, destDir, appName, saveOpts = {} }) {
236
257
  try {
237
258
  for await (const part of req.files(saveOpts.filesOptions)) {
238
259
  const safeName = sanitizeFilename(part.filename)
239
- const abs = resolve(root, safeName)
260
+ // 저장 파일명은 유일화(동일명 동시/연속 업로드 silent 덮어쓰기·스트림 race 차단). 표시명
261
+ // (반환 meta.filename)은 살균 원본 유지.
262
+ const abs = resolve(root, uniquifyFilename(safeName))
240
263
  // 이중 방어 — 살균했어도 최종 경로가 root 내부(root/<파일>)가 아니면 거부.
241
264
  if (!abs.startsWith(root + sep)) {
242
265
  throw new Error(`upload.unsafe_path: resolved path escapes destDir (name='${part.filename}')`)
@@ -0,0 +1,131 @@
1
+ // @ts-check
2
+ /**
3
+ * HTTP 라우트 라이프사이클 Pipeline — before/transform/after 합성의 단일 수렴점 (ADR-185).
4
+ *
5
+ * 배경: 미들웨어 wiring(arity 흡수·ctx 주입·after 에러 정책)이 router.js(라우트 등록)와
6
+ * mega-app.js(글로벌 미들웨어)에 비슷하지만 미세하게 다른 복사본으로 흩어져 있었고, 그래서
7
+ * `router.use` 의 arity 부팅 거부(ADR-184 H2) 같은 사각이 생겼다. 이 모듈이 합성 로직의
8
+ * 정본이다 — 신규 등록 경로는 반드시 여기를 거친다(복사본 금지).
9
+ *
10
+ * 합성 규칙 (ADR-091/134/156/184):
11
+ * - before: 각각 arity-2 async 래퍼 + canonical ctx 주입 → Fastify preHandler 배열.
12
+ * (Fastify 는 async hook 의 arity 3 이상을 done 콜백으로 오인해 등록을 거부하므로 래퍼 필수.
13
+ * 래퍼가 독립 preHandler 라 순서·reply 단락 의미는 보존된다.)
14
+ * - transform: 순차 await 체인(payload 변환) → 단일 preSerialization. envelope wrap 은
15
+ * MegaApp onRoute 가 체인 맨 끝에 별도 append(ADR-076 — transform → wrap 순서 보장).
16
+ * - after: onResponse(응답 전송 후). throw 는 warn 로그 후 무시 — 응답엔 영향 없음,
17
+ * silent 금지(P4, ADR-091).
18
+ *
19
+ * 파일/앱/전역 레벨 transform·after 슬롯(ADR-021 의 전체 체인)은 Stage 2 — 등록 API 정본
20
+ * 설계 후 이 모듈에 추가한다(ADR-185).
21
+ *
22
+ * @module core/pipeline
23
+ */
24
+ import { getLazyHttpCtx } from './ctx-builder.js'
25
+
26
+ /**
27
+ * 미들웨어를 Fastify 가 async preHandler 로 인식하는 arity-2 래퍼로 감싸고, canonical ctx 를
28
+ * 3번째 인자로 주입한다 — 핸들러·글로벌 미들웨어와 동일한 `(req, reply, ctx)` 계약(ADR-134/184).
29
+ * ctx 는 **lazy 프록시**(ADR-214) — 미들웨어가 실제로 속성을 만질 때만 빌드되고, 요청당 캐싱이라
30
+ * 같은 요청의 모든 미들웨어·핸들러가 동일 ctx 객체를 공유한다.
31
+ *
32
+ * @param {Function} fn - `(req, reply, ctx?)` 미들웨어 (arity-2 도 하위 호환 — 3번째 인자 무시).
33
+ * @param {import('./mega-app.js').MegaApp | null} [app] - ctx 의 어댑터·서비스 접근자 출처(없으면 null).
34
+ * @returns {(req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<any>}
35
+ */
36
+ export function wrapPreHandler(fn, app) {
37
+ return async (req, reply) => fn(req, reply, getLazyHttpCtx({ app: app ?? null, req, reply }))
38
+ }
39
+
40
+ /**
41
+ * transform 배열을 단일 preSerialization 함수로 합성한다 — 순차 await, 각 변환의 반환값이
42
+ * 다음 변환의 payload 가 된다(raw data 만 다룸 — envelope 는 이후 단계, ADR-091).
43
+ *
44
+ * @param {Function[]} transforms
45
+ * @returns {(req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply, payload: any) => Promise<any>}
46
+ */
47
+ export function composeTransform(transforms) {
48
+ return async (req, reply, payload) => {
49
+ let current = payload
50
+ for (const fn of transforms) {
51
+ current = await fn(req, reply, current)
52
+ }
53
+ return current
54
+ }
55
+ }
56
+
57
+ /**
58
+ * after 배열을 단일 onResponse 함수로 합성한다 — 응답 전송 후 side-effect 전용.
59
+ * 개별 after 의 throw 는 warn 로그 후 다음 after 로 진행(응답 영향 없음, ADR-091 / P4).
60
+ *
61
+ * @param {Function[]} afters
62
+ * @param {{ method: string, path: string }} route - warn 로그 식별용.
63
+ * @returns {(req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>}
64
+ */
65
+ export function composeAfter(afters, { method, path }) {
66
+ return async (req, reply) => {
67
+ for (const fn of afters) {
68
+ try {
69
+ await fn(req, reply)
70
+ } catch (err) {
71
+ // ADR-091: silent fallback 금지 — warn 로그.
72
+ const log = req.log ?? console
73
+ log.warn?.({ err, hook: 'after', method, path }, `after middleware threw — ignored (response already sent)`)
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @typedef {object} HttpPipeline
81
+ * @property {(req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<any>} handler -
82
+ * canonical `(req, reply, ctx)` 주입이 적용된 Fastify 핸들러.
83
+ * @property {Function[]} [preHandler] - before 래퍼 배열 (before 없으면 생략).
84
+ * @property {Function} [preSerialization] - transform 합성 (transform 없으면 생략).
85
+ * @property {Function} [onResponse] - after 합성 (after 없으면 생략).
86
+ * @property {() => { method: string, path: string, before: string[], transform: string[], after: string[] }} describe -
87
+ * 체인 introspection — 단계별 미들웨어 이름 목록(익명은 `(anonymous)`).
88
+ */
89
+
90
+ /**
91
+ * 라우트 1개의 HTTP 라이프사이클을 합성한다. Router._registerHttp 가 Fastify routeOpts 로 옮긴다.
92
+ *
93
+ * @param {object} args
94
+ * @param {import('./mega-app.js').MegaApp | null} args.app - ctx 출처 (standalone Router 면 null).
95
+ * @param {string} args.method - HTTP 메서드 (소문자).
96
+ * @param {string} args.path - 라우트 경로.
97
+ * @param {Function} args.handler - 사용자 핸들러 (inline 또는 static method ref, ADR-074).
98
+ * @param {Function[]} [args.before]
99
+ * @param {Function[]} [args.transform]
100
+ * @param {Function[]} [args.after]
101
+ * @returns {HttpPipeline}
102
+ */
103
+ export function buildHttpPipeline({ app = null, method, path, handler, before = [], transform = [], after = [] }) {
104
+ /** @type {HttpPipeline} */
105
+ const pipeline = {
106
+ // canonical 핸들러 시그니처 (req, res, ctx) (ADR-074, docs/03 §581). ctx 는 lazy 프록시
107
+ // (ADR-214) — 핸들러가 안 쓰면 buildHttpCtx 비용(1.6 KB + 0.7 µs/req, G1 H-1)이 발생하지 않고,
108
+ // 첫 접근부터는 요청당 1회 캐싱이라 글로벌·before 미들웨어가 먼저 만든 ctx 를 그대로 이어받는다(ADR-134).
109
+ handler: async (req, reply) => handler(req, reply, getLazyHttpCtx({ app, req, reply })),
110
+ describe: () => ({
111
+ method,
112
+ path,
113
+ before: before.map(fnName),
114
+ transform: transform.map(fnName),
115
+ after: after.map(fnName),
116
+ }),
117
+ }
118
+ if (before.length > 0) pipeline.preHandler = before.map((fn) => wrapPreHandler(fn, app))
119
+ if (transform.length > 0) pipeline.preSerialization = composeTransform(transform)
120
+ if (after.length > 0) pipeline.onResponse = composeAfter(after, { method, path })
121
+ return pipeline
122
+ }
123
+
124
+ /**
125
+ * 미들웨어 함수 이름 (introspection 표기용).
126
+ * @param {Function} fn
127
+ * @returns {string}
128
+ */
129
+ function fnName(fn) {
130
+ return fn.name || '(anonymous)'
131
+ }