mega-framework 0.1.7 → 0.1.9

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 (95) hide show
  1. package/README.md +9 -0
  2. package/package.json +3 -3
  3. package/sample/crud/.env +9 -0
  4. package/sample/crud/.env.example +9 -0
  5. package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
  6. package/sample/crud/apps/main/locales/server/en.json +12 -1
  7. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  8. package/sample/crud/apps/main/routes/upload.js +20 -1
  9. package/sample/crud/apps/main/services/guide-service.js +4 -3
  10. package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
  11. package/sample/crud/apps/main/views/upload/index.ejs +4 -1
  12. package/sample/crud/docs/guide/01-cli.md +587 -0
  13. package/sample/crud/docs/guide/02-router-controller.md +497 -0
  14. package/sample/crud/docs/guide/03-service-model-db.md +929 -0
  15. package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
  16. package/sample/crud/docs/guide/05-scheduler-job-worker.md +400 -0
  17. package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
  18. package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
  19. package/sample/crud/docs/guide/08-observability.md +373 -0
  20. package/sample/crud/mega.config.js +7 -0
  21. package/sample/crud/package.json +2 -2
  22. package/sample/crud/scripts/start-ws-hub.sh +18 -4
  23. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbnwq5v-d2125aa8.txt" +1 -0
  24. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbo0nbf-842b6135.txt" +1 -0
  25. package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
  26. package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
  27. package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
  28. package/sample/crud/var/uploads//341/204/200/341/205/247/341/206/274/341/204/200/341/205/265/341/204/211/341/205/265/341/206/257/341/204/214/341/205/245/341/206/250/341/204/214/341/205/263/341/206/274/341/204/206/341/205/247/341/206/274/341/204/211/341/205/245-mqbo5yxh-5288d8ef.pdf +0 -0
  29. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  30. package/src/adapters/adapter-options.js +14 -3
  31. package/src/adapters/file-adapter.js +9 -5
  32. package/src/adapters/file-session-adapter.js +4 -3
  33. package/src/adapters/maria-adapter.js +7 -4
  34. package/src/adapters/mega-cache-adapter.js +83 -6
  35. package/src/adapters/mega-db-adapter.js +4 -1
  36. package/src/adapters/mongo-adapter.js +21 -7
  37. package/src/adapters/postgres-adapter.js +8 -4
  38. package/src/adapters/redis-adapter.js +7 -3
  39. package/src/adapters/sqlite-adapter.js +6 -2
  40. package/src/cli/commands/console-cmd.js +3 -1
  41. package/src/cli/commands/scaffold.js +38 -2
  42. package/src/cli/generators/index.js +58 -1
  43. package/src/cli/index.js +88 -59
  44. package/src/cli/watch.js +188 -0
  45. package/src/core/ajv-mapper.js +3 -1
  46. package/src/core/ctx-builder.js +59 -1
  47. package/src/core/envelope.js +9 -2
  48. package/src/core/hub-link.js +24 -14
  49. package/src/core/index.js +1 -1
  50. package/src/core/mega-app.js +55 -45
  51. package/src/core/pipeline.js +8 -6
  52. package/src/core/scope-registry.js +1 -0
  53. package/src/core/security.js +3 -3
  54. package/src/core/session-store.js +14 -1
  55. package/src/core/ws-presence.js +17 -5
  56. package/src/core/ws-roster.js +49 -10
  57. package/src/core/ws-upgrade.js +105 -0
  58. package/src/lib/mega-circuit-breaker.js +5 -3
  59. package/src/lib/mega-health.js +10 -0
  60. package/src/lib/mega-job-queue.js +53 -13
  61. package/src/lib/mega-job.js +8 -1
  62. package/src/lib/mega-metrics.js +28 -1
  63. package/src/lib/mega-plugin.js +2 -2
  64. package/src/lib/mega-worker.js +28 -5
  65. package/src/lib/ws-hub.js +90 -9
  66. package/templates/adr/code.tpl +23 -0
  67. package/types/adapters/adapter-options.d.ts +2 -0
  68. package/types/adapters/file-adapter.d.ts +12 -1
  69. package/types/adapters/file-session-adapter.d.ts +4 -2
  70. package/types/adapters/maria-adapter.d.ts +5 -3
  71. package/types/adapters/mega-cache-adapter.d.ts +27 -1
  72. package/types/adapters/mega-db-adapter.d.ts +4 -1
  73. package/types/adapters/mongo-adapter.d.ts +13 -2
  74. package/types/adapters/postgres-adapter.d.ts +4 -2
  75. package/types/adapters/redis-adapter.d.ts +8 -0
  76. package/types/adapters/sqlite-adapter.d.ts +8 -2
  77. package/types/cli/generators/index.d.ts +11 -1
  78. package/types/cli/index.d.ts +12 -27
  79. package/types/cli/watch.d.ts +59 -0
  80. package/types/core/ctx-builder.d.ts +23 -0
  81. package/types/core/hub-link.d.ts +3 -1
  82. package/types/core/index.d.ts +1 -1
  83. package/types/core/mega-app.d.ts +1 -1
  84. package/types/core/pipeline.d.ts +2 -1
  85. package/types/core/security.d.ts +3 -3
  86. package/types/core/session-store.d.ts +7 -0
  87. package/types/core/ws-roster.d.ts +13 -1
  88. package/types/core/ws-upgrade.d.ts +29 -0
  89. package/types/lib/mega-circuit-breaker.d.ts +4 -2
  90. package/types/lib/mega-health.d.ts +7 -0
  91. package/types/lib/mega-job-queue.d.ts +16 -4
  92. package/types/lib/mega-job.d.ts +8 -1
  93. package/types/lib/mega-plugin.d.ts +1 -1
  94. package/types/lib/mega-worker.d.ts +3 -1
  95. package/types/lib/ws-hub.d.ts +27 -2
package/src/cli/index.js CHANGED
@@ -19,26 +19,20 @@
19
19
  *
20
20
  * @module cli/index
21
21
  */
22
+ // 무거운 런타임 그래프(boot→fastify·OTel·pino·ejs·보안 플러그인, 클러스터, 마이그레이션 러너)는
23
+ // 정적 import 하지 않는다 — `mega help`/`g`/`new` 같은 비부팅 명령이 전체 그래프를 평가해 4배
24
+ // 느려지는 것을 막기 위해, 각 런타임 명령의 action/호스트 함수 내부에서 dynamic import 한다.
25
+ // 모듈 레벨 싱글톤(adapter-manager·MegaShutdown 등)은 dynamic import 도 같은 인스턴스를 본다.
22
26
  import { existsSync } from 'node:fs'
23
27
  import { join } from 'node:path'
24
28
  import os from 'node:os'
25
29
  import { spawn as nodeSpawn } from 'node:child_process'
26
- import { bootApp, prepareRuntime } from '../core/boot.js'
27
- import { MegaCluster } from '../core/mega-cluster.js'
28
- import { installPrimaryAggregator, installWorkerResponder } from '../core/cluster-metrics.js'
29
30
  import { loadAndValidateConfig } from '../core/config-loader.js'
30
- import { migrateUp, migrateDown, migrateStatus } from '../core/migration-runner.js'
31
- import { withMigrationLock } from '../core/migration-lock.js'
32
- import { runMigrateGenerate } from '../core/migration/generate.js'
33
31
  import { buildFromGlobalConfig, connectAll, disconnectAll, get as getAdapter } from '../adapters/adapter-manager.js'
34
32
  import { MegaDbAdapter } from '../adapters/mega-db-adapter.js'
35
33
  import { MegaConfigError } from '../errors/config-error.js'
36
34
  import { MegaPluginHost, loadPlugins } from '../lib/mega-plugin.js'
37
- import { MegaJobWorker } from '../lib/mega-job-worker.js'
38
- import { MegaScheduler } from '../lib/mega-schedule.js'
39
35
  import { MegaShutdown, DEFAULT_HARD_KILL_MS } from '../lib/mega-shutdown.js'
40
- import { buildLogger } from '../lib/mega-logger.js'
41
- import * as MegaMetrics from '../lib/mega-metrics.js'
42
36
  import { Command } from 'commander'
43
37
  import { registerScaffoldCommands, commanderErrorToExitCode } from './commands/scaffold.js'
44
38
 
@@ -240,37 +234,35 @@ export function shouldReexecForWatch(flags, env) {
240
234
  }
241
235
 
242
236
  /**
243
- * `mega start --watch` `node --watch <self> start … --cluster 1` 재실행 명령을 빌드한다 (ADR-182).
237
+ * `mega start --watch` supervisor **자식 인자**를 빌드한다 (ADR-220, ADR-182 개정).
244
238
  *
245
- * - `--watch` 인자는 **node 플래그**로 옮기고 mega 인자에선 제거한다.
239
+ * - `--watch`/`--watch-ignore`(값 포함)는 부모(supervisor) 전용 자식 인자에서 제거한다
240
+ * (가드 env 와 이중 안전으로 무한재귀 방지).
246
241
  * - **단일 프로세스 강제**(`--cluster 1`) — 멀티워커 watch 카오스 방지(기존 `--cluster` 값은 무시).
247
- * - watchPaths 가 있으면 `--watch-path=…`(이게 모듈 그래프 watch 를 **대체**하므로 앱 소스·전역설정 경로를
248
- * 명시해야 한다 — 실측 확인). 없으면 plain `--watch`(모듈 그래프 폴백).
249
242
  *
250
- * @param {Object} o
251
- * @param {string[]} o.argv - 원본 mega argv(예: `['start','--watch','--port','3000']`).
252
- * @param {string[]} o.watchPaths - 감시할 절대경로(존재하는 것만).
253
- * @param {string} o.selfPath - mega 진입 스크립트(`process.argv[1]`).
254
- * @param {string} o.execPath - node 바이너리(`process.execPath`).
255
- * @returns {{ command: string, args: string[] }}
243
+ * @param {string[]} argv - 원본 mega argv(예: `['start','--watch','--port','3000']`).
244
+ * @returns {string[]} 자식 `mega` 인자(예: `['start','--port','3000','--cluster','1']`).
256
245
  */
257
- export function buildWatchCommand({ argv, watchPaths, selfPath, execPath }) {
258
- const nodeFlags =
259
- Array.isArray(watchPaths) && watchPaths.length > 0 ? watchPaths.map((p) => `--watch-path=${p}`) : ['--watch']
260
- /** @type {string[]} mega 인자 — `--watch` 제거 + `--cluster`(값 포함) 제거. */
246
+ export function buildWatchChildArgs(argv) {
247
+ /** @type {string[]} */
261
248
  const megaArgs = []
262
249
  for (let i = 0; i < argv.length; i++) {
263
250
  const tok = argv[i]
264
251
  if (tok === '--watch' || tok.startsWith('--watch=')) continue
265
- if (tok === '--cluster') {
252
+ if (tok === '--watch-ignore') {
266
253
  if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) i++ // 값 토큰도 건너뜀.
267
254
  continue
268
255
  }
256
+ if (tok.startsWith('--watch-ignore=')) continue
257
+ if (tok === '--cluster') {
258
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) i++
259
+ continue
260
+ }
269
261
  if (tok.startsWith('--cluster=')) continue
270
262
  megaArgs.push(tok)
271
263
  }
272
264
  megaArgs.push('--cluster', '1') // 단일 프로세스 강제.
273
- return { command: execPath, args: [...nodeFlags, selfPath, ...megaArgs] }
265
+ return megaArgs
274
266
  }
275
267
 
276
268
  /**
@@ -338,38 +330,47 @@ export async function runCli(argv, { out = console.log, err = console.error, cwd
338
330
 
339
331
  /**
340
332
  * `mega start`/`serve` 본체 — dev watch 재실행 또는 부팅+listen(단일/클러스터).
341
- * @param {{ port?: string, host?: string, cluster?: string | boolean, watch?: boolean }} opts - commander 옵션.
333
+ * @param {{ port?: string, host?: string, cluster?: string | boolean, watch?: boolean, watchIgnore?: string }} opts - commander 옵션.
342
334
  * @param {{ argv: string[], out: (msg: string) => void, projectRoot: string, logger?: { debug?: Function, info?: Function, warn?: Function }, spawn: Function, selfPath: string }} ctx
343
335
  * @returns {Promise<number>} exit code.
344
336
  */
345
337
  async function runStart(opts, { argv, out, projectRoot, logger, spawn, selfPath }) {
346
338
  {
347
- // dev watch (ADR-182) — `--watch` 면 자신을 `node --watch` 로 재실행한다. 부모는 부팅하지 않고 watcher
348
- // 자식만 띄운 exit code 그대로 반영한다(가드 env 무한재귀 방지). 단일 프로세스 강제 +
349
- // NODE_ENV 미설정 development 기본. 시그널은 자식으로 전달해 Ctrl+C 가 watcher 까지 닿게 한다.
339
+ // dev watch (ADR-220, ADR-182 개정) — `--watch` 면 자체 supervisor 가 자식(`mega start`)을 띄우고
340
+ // fs.watch(recursive) + ignore 패턴 + debounce 재시작을 조율한다. node 내장 --watch ignore
341
+ // 없어 런타임 산출물(locales saveMissing·업로드·journal)이 재시작 루프를 만들었다. 단일 프로세스
342
+ // 강제 + NODE_ENV 미설정 시 development 기본 + 가드 env 로 무한재귀 방지는 기존과 동일.
350
343
  if (shouldReexecForWatch({ watch: opts.watch === true }, process.env)) {
351
- const watchPaths = [join(projectRoot, 'apps'), join(projectRoot, 'mega.config.js')].filter(existsSync)
352
- const { command: cmd, args } = buildWatchCommand({ argv, watchPaths, selfPath, execPath: process.execPath })
353
- out(`mega: watch mode (single process) restarting on changes in [${watchPaths.length ? watchPaths.join(', ') : 'module graph'}]`)
354
- const child = spawn(cmd, args, {
355
- stdio: 'inherit',
356
- env: { ...process.env, MEGA_WATCH_REEXEC: '1', NODE_ENV: process.env.NODE_ENV ?? 'development' },
357
- })
358
- /** @type {NodeJS.Signals[]} */
359
- const sigs = ['SIGINT', 'SIGTERM']
360
- const forward = (/** @type {NodeJS.Signals} */ sig) => {
361
- try {
362
- child.kill(sig)
363
- } catch {
364
- // 자식이 이미 종료 중이면 kill 은 무의미 — 무시(비치명적).
365
- }
344
+ const { mergeWatchIgnore, startWatchSupervisor, defaultWatchPaths } = await import('./watch.js')
345
+ // config watch.ignore best-effort dev 루프는 config 깨진 동안에도 살아 있어야
346
+ // 하므로(고치면 재시작) 로드 실패 디폴트 ignore 진행하고 이유를 알린다.
347
+ /** @type {{ ignore?: unknown, ignoreDefaults?: unknown }} */
348
+ let watchConfig = {}
349
+ try {
350
+ const { global } = await loadAndValidateConfig(projectRoot)
351
+ watchConfig = /** @type {any} */ (global).watch ?? {}
352
+ } catch (e) {
353
+ out(`mega: watch mega.config.js 를 읽지 못해 디폴트 ignore 로 감시합니다(${/** @type {any} */ (e).message?.split('\n')[0] ?? e}).`)
354
+ }
355
+ const ignore = mergeWatchIgnore(watchConfig, typeof opts.watchIgnore === 'string' ? opts.watchIgnore : undefined)
356
+ const watchPaths = defaultWatchPaths(projectRoot)
357
+ const childArgs = buildWatchChildArgs(argv)
358
+ out(`mega: watch mode (single process) — watching [${watchPaths.join(', ')}], ignore ${ignore.length} patterns`)
359
+ if (ignore.length === 0) {
360
+ // 숨은 디폴트가 없으므로(ADR-220) 미설정은 "모든 변경이 재시작" — locales(saveMissing 자동
361
+ // 기입)·업로드 같은 런타임 쓰기 영역이 있으면 루프가 날 수 있어 가시화한다(P4).
362
+ out('mega: watch ignore 가 비어 있습니다 — locales/업로드 등 런타임 쓰기 폴더가 있으면 .env 의 WATCH_IGNORE(또는 config watch.ignore) 설정을 권장합니다.')
366
363
  }
367
- for (const s of sigs) process.on(s, forward)
368
- return await new Promise((resolve) => {
369
- child.on('exit', (/** @type {number|null} */ code) => {
370
- for (const s of sigs) process.removeListener(s, forward)
371
- resolve(typeof code === 'number' ? code : 0)
372
- })
364
+ return await startWatchSupervisor({
365
+ projectRoot,
366
+ watchPaths,
367
+ ignore,
368
+ out,
369
+ spawnChild: () =>
370
+ spawn(process.execPath, [selfPath, ...childArgs], {
371
+ stdio: 'inherit',
372
+ env: { ...process.env, MEGA_WATCH_REEXEC: '1', NODE_ENV: process.env.NODE_ENV ?? 'development' },
373
+ }),
373
374
  })
374
375
  }
375
376
 
@@ -394,6 +395,15 @@ async function runStart(opts, { argv, out, projectRoot, logger, spawn, selfPath
394
395
  else out(`mega: ${msg}`)
395
396
  }
396
397
 
398
+ // 런타임 그래프 lazy 로드 — 부팅 명령에서만 프레임워크 전체를 지불한다(모듈 상단 주석).
399
+ const [{ bootApp }, { MegaCluster }, { installPrimaryAggregator, installWorkerResponder }, { buildLogger }] =
400
+ await Promise.all([
401
+ import('../core/boot.js'),
402
+ import('../core/mega-cluster.js'),
403
+ import('../core/cluster-metrics.js'),
404
+ import('../lib/mega-logger.js'),
405
+ ])
406
+
397
407
  // 워커 부팅 함수 — 단일/클러스터 모드 공통. 클러스터에선 각 워커 프로세스가 이 함수를 실행한다.
398
408
  const bootListen = async () => {
399
409
  const { server, appLogger } = await bootApp(projectRoot, { listen: true, port, host, logger })
@@ -485,7 +495,8 @@ function buildProgram({ argv, out, err, projectRoot, logger, spawn, selfPath, se
485
495
  '--cluster [n]',
486
496
  "워커 프로세스 수 — 정수 N 또는 'max'(CPU 코어 수, 베어 플래그도 max). 우선순위: 플래그 > MEGA_CLUSTER_WORKERS env > server.cluster config. 1/미지정 = 단일 프로세스",
487
497
  )
488
- .option('--watch', 'dev watch — 파일 변경 시 자동 재시작(node 내장 --watch 재실행, 단일 프로세스 강제)')
498
+ .option('--watch', 'dev watch — 파일 변경 시 자동 재시작(단일 프로세스 강제). locales/views/public/uploads/var/.mega/.env* 등은 디폴트 ignore')
499
+ .option('--watch-ignore <globs>', 'watch 추가 ignore(콤마 구분 glob, 디폴트에 더해짐). config watch.ignore 와 병합')
489
500
  .action(async (/** @type {any} */ opts) => {
490
501
  setExit(await runStart(opts, { argv, out, projectRoot, logger, spawn, selfPath }))
491
502
  })
@@ -539,6 +550,7 @@ function buildProgram({ argv, out, err, projectRoot, logger, spawn, selfPath, se
539
550
  .option('--adapter <key>', '특정 adapter(services.databases 키)만 처리(기본: 전부)')
540
551
  .option('--app <name>', '마이그레이션 파일을 둘 앱(기본: main)', 'main')
541
552
  .action(async (/** @type {string | undefined} */ name, /** @type {any} */ opts) => {
553
+ const { runMigrateGenerate } = await import('../core/migration/generate.js')
542
554
  const r = await runMigrateGenerate(projectRoot, {
543
555
  name,
544
556
  renames: opts.renames,
@@ -662,10 +674,11 @@ export function collectRegistrations(global, host, kind) {
662
674
  * 주입(테스트)이 있으면 그쪽을 그대로 쓰고 pino 는 만들지 않는다(side-effect 회피).
663
675
  * @param {Object} global - global config(`logger` 보유 가능).
664
676
  * @param {{ info?: Function, warn?: Function } | undefined} injectedLogger - 주입 로거(테스트) 또는 undefined.
665
- * @returns {{ info?: Function, warn?: Function } | null} 호스트 로그에 쓸 로거(없으면 null).
677
+ * @returns {Promise<{ info?: Function, warn?: Function } | null>} 호스트 로그에 쓸 로거(없으면 null).
666
678
  */
667
- function wireHostLogger(global, injectedLogger) {
679
+ async function wireHostLogger(global, injectedLogger) {
668
680
  if (injectedLogger) return injectedLogger
681
+ const { buildLogger } = await import('../lib/mega-logger.js') // pino 그래프 — 호스트 경로에서만 로드.
669
682
  const appLogger = buildLogger(/** @type {any} */ (global).logger)
670
683
  if (appLogger) {
671
684
  // 종료 시퀀스 로그를 pino 로(ADR-178) + graceful 시 로그 버퍼 flush. 'logs' stage 라 종료
@@ -683,12 +696,17 @@ function wireHostLogger(global, injectedLogger) {
683
696
  * 등록 소스 = `config.jobs` + 플러그인 `mega.jobs.register` 분(`collectRegistrations`, ADR-123).
684
697
  * @param {string} projectRoot
685
698
  * @param {{ debug?: Function, info?: Function, warn?: Function }} [logger]
686
- * @returns {Promise<MegaJobWorker>}
699
+ * @returns {Promise<import('../lib/mega-job-worker.js').MegaJobWorker>}
687
700
  */
688
701
  export async function runWorkerHost(projectRoot, logger) {
689
- // bootApp 과 같은 토대(config → 플러그인 install → 어댑터 connect → ctx).
702
+ // bootApp 과 같은 토대(config → 플러그인 install → 어댑터 connect → ctx). 런타임 그래프는 lazy.
703
+ const [{ prepareRuntime }, { MegaJobWorker }, MegaMetrics] = await Promise.all([
704
+ import('../core/boot.js'),
705
+ import('../lib/mega-job-worker.js'),
706
+ import('../lib/mega-metrics.js'),
707
+ ])
690
708
  const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
691
- const log = wireHostLogger(global, logger)
709
+ const log = await wireHostLogger(global, logger)
692
710
  const worker = new MegaJobWorker({ ctx })
693
711
  // 잡 메트릭 (ADR-132) — prepareRuntime 이 health.exposeMetrics 시 이미 MegaMetrics.init 했고,
694
712
  // 옵트인 OFF 면 subscribeJobs 는 no-op() 을 반환한다. start 전에 구독해 enqueue(dispatch)부터 잡는다. 구독은
@@ -712,11 +730,15 @@ export async function runWorkerHost(projectRoot, logger) {
712
730
  * `mega scheduler` 호스트 골격 — config + 어댑터 connect + ctx + `MegaScheduler` 인스턴스 + graceful.
713
731
  * @param {string} projectRoot
714
732
  * @param {{ debug?: Function, info?: Function, warn?: Function }} [logger]
715
- * @returns {Promise<MegaScheduler>}
733
+ * @returns {Promise<import('../lib/mega-schedule.js').MegaScheduler>}
716
734
  */
717
735
  export async function runSchedulerHost(projectRoot, logger) {
736
+ const [{ prepareRuntime }, { MegaScheduler }] = await Promise.all([
737
+ import('../core/boot.js'),
738
+ import('../lib/mega-schedule.js'),
739
+ ])
718
740
  const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
719
- const log = wireHostLogger(global, logger)
741
+ const log = await wireHostLogger(global, logger)
720
742
  const scheduler = new MegaScheduler({ ctx })
721
743
  // 등록 소스(ADR-123) = config.schedules(정적) + 플러그인 host.listSchedules()(동적). register 가
722
744
  // cron 미선언·중복을 부팅 시 fail-fast.
@@ -807,6 +829,13 @@ export async function runMigrateHost(projectRoot, { direction = 'up', dbKey, log
807
829
  info: (/** @type {any} */ o, /** @type {string} */ msg) => out(`[migrate] ${fmt(o, msg)}`),
808
830
  warn: (/** @type {any} */ o, /** @type {string} */ msg) => out(`[migrate] 경고: ${fmt(o, msg)}`),
809
831
  }
832
+ // 빌트인 어댑터 배럴을 명시 로드(ADR-150) — migrate 는 boot.js 를 거치지 않고 buildFromGlobalConfig
833
+ // 를 직접 부르므로, boot 정적 import 가 사라진 lazy CLI 에선 여기서 driver 자기등록을 보장해야 한다.
834
+ const [{ migrateUp, migrateDown, migrateStatus }, { withMigrationLock }] = await Promise.all([
835
+ import('../core/migration-runner.js'),
836
+ import('../core/migration-lock.js'),
837
+ import('../adapters/index.js'),
838
+ ])
810
839
  const { global, apps } = await loadAndValidateConfig(projectRoot)
811
840
  // 플러그인 install 을 어댑터 build 보다 먼저(config-driven driver 제약, boot.js 주석 참조).
812
841
  const host = new MegaPluginHost({ logger })
@@ -0,0 +1,188 @@
1
+ // @ts-check
2
+ /**
3
+ * `mega start --watch` 의 dev watch supervisor (ADR-220, ADR-182 개정).
4
+ *
5
+ * Node 내장 `--watch-path` 재실행(구 ADR-182)은 **ignore 패턴이 없어** 런타임이 쓰는 파일까지
6
+ * 재시작을 유발했다 — dev 의 i18n saveMissing 이 `locales/*.json` 자동 기입(재시작 무한 루프),
7
+ * 업로드가 `uploads/`/`var/` 에 파일 저장(사용자 입력이 서버를 재시작), 마이그레이션 generate 가
8
+ * `.mega/` journal 갱신. 본 모듈이 그 재실행을 **자체 supervisor**(fs.watch recursive + 미니 glob
9
+ * ignore + debounce + SIGTERM respawn)로 대체한다 — 신규 dep 0(chokidar 미도입: 필요한 매칭이
10
+ * `**`/`*`/`?` 수준이라 풀스펙 glob 라이브러리가 과함).
11
+ *
12
+ * ignore 합성 = config `watch.ignore` + CLI `--watch-ignore`. **숨은 코드 디폴트는 없다**(사용자
13
+ * 결정 — 개발자가 보지 못하는 목록은 폴더명이 다른 프로젝트에서 오히려 혼란) — 권장 목록은 스캐폴드
14
+ * `.env` 의 `WATCH_IGNORE` 가 실값으로 명시하고 `mega.config.js` 가 읽어 `watch.ignore` 로 넘긴다.
15
+ * ignore 0 이면 supervisor 가 기동 시 경고 1줄을 낸다(locales saveMissing 루프 등 위험 가시화).
16
+ *
17
+ * @module cli/watch
18
+ */
19
+ import { watch as fsWatch, existsSync, statSync } from 'node:fs'
20
+ import { join, relative, sep } from 'node:path'
21
+
22
+ /**
23
+ * 미니 glob → RegExp. 지원: 더블스타+슬래시(0개 이상의 디렉토리), 더블스타(경로 전체), `*`(세그먼트
24
+ * 내), `?`(1글자). 그 외 문자는 리터럴(정규식 특수문자 escape). 매칭 대상은 루트 상대 POSIX 경로
25
+ * 전체(^…$). 패턴 예시는 스캐폴드 `.env` 의 `WATCH_IGNORE` 참조 — JS 블록 주석 안에는
26
+ * 더블스타+슬래시 시퀀스를 쓸 수 없어(주석 조기 종료) 여기 직접 못 적는다.
27
+ *
28
+ * @param {string} pattern - glob 패턴(예 `.env.*`).
29
+ * @returns {RegExp}
30
+ */
31
+ export function globToRegExp(pattern) {
32
+ let re = ''
33
+ let i = 0
34
+ while (i < pattern.length) {
35
+ const ch = pattern[i]
36
+ if (ch === '*') {
37
+ if (pattern[i + 1] === '*') {
38
+ // `**/` 는 0개 이상의 디렉토리(루트 레벨 포함), 그 외 `**` 는 임의 문자열.
39
+ if (pattern[i + 2] === '/') {
40
+ re += '(?:.*/)?'
41
+ i += 3
42
+ } else {
43
+ re += '.*'
44
+ i += 2
45
+ }
46
+ } else {
47
+ re += '[^/]*'
48
+ i += 1
49
+ }
50
+ } else if (ch === '?') {
51
+ re += '[^/]'
52
+ i += 1
53
+ } else {
54
+ re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
55
+ i += 1
56
+ }
57
+ }
58
+ return new RegExp(`^${re}$`)
59
+ }
60
+
61
+ /**
62
+ * ignore 패턴 합성 — config(`watch.ignore`) + CLI(`--watch-ignore a,b`). 숨은 디폴트 없음(모듈
63
+ * docstring 참조). 잘못된 모양은 fail-fast(silent 무시 금지).
64
+ *
65
+ * @param {{ ignore?: unknown }} [watchConfig] - mega.config.js 의 `watch`.
66
+ * @param {string | undefined} [cliIgnore] - `--watch-ignore` 값(콤마 구분).
67
+ * @returns {string[]}
68
+ * @throws {TypeError} config `watch.ignore` 가 문자열 배열이 아닐 때.
69
+ */
70
+ export function mergeWatchIgnore(watchConfig = {}, cliIgnore = undefined) {
71
+ /** @type {string[]} */
72
+ const merged = []
73
+ if (watchConfig.ignore !== undefined) {
74
+ if (!Array.isArray(watchConfig.ignore) || watchConfig.ignore.some((p) => typeof p !== 'string' || p.length === 0)) {
75
+ throw new TypeError('config watch.ignore 는 비어있지 않은 문자열 배열이어야 합니다.')
76
+ }
77
+ merged.push(...watchConfig.ignore)
78
+ }
79
+ if (typeof cliIgnore === 'string' && cliIgnore.length > 0) {
80
+ merged.push(...cliIgnore.split(',').map((p) => p.trim()).filter((p) => p.length > 0))
81
+ }
82
+ return merged
83
+ }
84
+
85
+ /**
86
+ * 루트 상대 경로가 ignore 에 걸리는지 (Boolean — `is*`).
87
+ * @param {string} rel - 루트 상대 POSIX 경로(예 `apps/main/locales/ko.json`).
88
+ * @param {RegExp[]} regexps - {@link globToRegExp} 컴파일 결과.
89
+ * @returns {boolean}
90
+ */
91
+ export function isIgnoredPath(rel, regexps) {
92
+ return regexps.some((r) => r.test(rel))
93
+ }
94
+
95
+ /**
96
+ * watch supervisor — 감시 경로의 변경(ignore 통과분)을 debounce 후 자식(`mega start`)을
97
+ * SIGTERM → respawn 한다. 자식이 스스로 죽으면(crash) 재시작하지 않고 다음 변경을 기다린다
98
+ * (crash-loop 방지 — nodemon 의 "app crashed - waiting for file changes" 와 동형).
99
+ *
100
+ * @param {object} o
101
+ * @param {string} o.projectRoot
102
+ * @param {string[]} o.watchPaths - 감시할 절대경로(존재하는 것만 — 호출측이 필터).
103
+ * @param {string[]} o.ignore - glob 패턴 목록.
104
+ * @param {() => import('node:child_process').ChildProcess} o.spawnChild - 자식 기동 팩토리(주입 — 테스트).
105
+ * @param {(msg: string) => void} o.out
106
+ * @param {number} [o.debounceMs=500] - 연속 변경 디바운스.
107
+ * @param {(dir: string, opts: object, cb: (event: string, filename: string | Buffer | null) => void) => { close(): void }} [o.watchFn] -
108
+ * fs.watch 주입(테스트). 기본 node:fs watch.
109
+ * @returns {Promise<number>} 부모(supervisor)의 exit code — SIGINT/SIGTERM 수신 시 자식 정리 후 0.
110
+ */
111
+ export function startWatchSupervisor({ projectRoot, watchPaths, ignore, spawnChild, out, debounceMs = 500, watchFn = /** @type {any} */ (fsWatch) }) {
112
+ const regexps = ignore.map(globToRegExp)
113
+ /** @type {import('node:child_process').ChildProcess | null} */
114
+ let child = null
115
+ /** @type {NodeJS.Timeout | null} */
116
+ let pending = null
117
+ let shuttingDown = false
118
+ let restarting = false
119
+
120
+ const start = () => {
121
+ child = spawnChild()
122
+ child.on('exit', (code, signal) => {
123
+ if (shuttingDown) return
124
+ if (restarting) {
125
+ restarting = false
126
+ start()
127
+ return
128
+ }
129
+ // 자식이 스스로 종료(crash/정상) — 재시작 폭주 대신 다음 변경 대기(P4: 이유 출력).
130
+ out(`mega: app exited (code=${code ?? `signal:${signal}`}) — waiting for file changes before restart`)
131
+ child = null
132
+ })
133
+ }
134
+
135
+ const scheduleRestart = (/** @type {string} */ rel) => {
136
+ if (pending) clearTimeout(pending)
137
+ pending = setTimeout(() => {
138
+ pending = null
139
+ out(`mega: restarting due to changes (${rel})`)
140
+ if (child && child.exitCode === null && !child.killed) {
141
+ restarting = true
142
+ child.kill('SIGTERM') // graceful — exit 핸들러가 respawn.
143
+ } else {
144
+ start() // crash 후 대기 상태 — 변경으로 재기동.
145
+ }
146
+ }, debounceMs)
147
+ }
148
+
149
+ /** @type {Array<{ close(): void }>} */
150
+ const watchers = []
151
+ for (const abs of watchPaths) {
152
+ const isDir = statSync(abs).isDirectory()
153
+ const watcher = watchFn(abs, { recursive: isDir }, (_event, filename) => {
154
+ // filename 은 감시 루트 상대(파일 watch 면 basename) — 루트 상대로 정규화해 ignore 매칭.
155
+ const name = typeof filename === 'string' ? filename : (filename ? String(filename) : '')
156
+ const rel = relative(projectRoot, isDir ? join(abs, name) : abs).split(sep).join('/')
157
+ if (isIgnoredPath(rel, regexps)) return
158
+ scheduleRestart(rel)
159
+ })
160
+ watchers.push(watcher)
161
+ }
162
+
163
+ start()
164
+
165
+ return new Promise((resolve) => {
166
+ /** @type {NodeJS.Signals[]} */
167
+ const sigs = ['SIGINT', 'SIGTERM']
168
+ const onSignal = () => {
169
+ shuttingDown = true
170
+ if (pending) clearTimeout(pending)
171
+ for (const w of watchers) w.close()
172
+ for (const s of sigs) process.removeListener(s, onSignal)
173
+ if (child && child.exitCode === null && !child.killed) {
174
+ child.once('exit', () => resolve(0))
175
+ child.kill('SIGTERM')
176
+ } else {
177
+ resolve(0)
178
+ }
179
+ }
180
+ for (const s of sigs) process.on(s, onSignal)
181
+ })
182
+ }
183
+
184
+ /** {@link startWatchSupervisor} 의 watchPaths 기본 집합 — 존재하는 것만.
185
+ * @param {string} projectRoot @returns {string[]} */
186
+ export function defaultWatchPaths(projectRoot) {
187
+ return [join(projectRoot, 'apps'), join(projectRoot, 'shared'), join(projectRoot, 'mega.config.js')].filter(existsSync)
188
+ }
@@ -74,7 +74,9 @@ function ajvItemToDetail(e) {
74
74
  if (e.keyword === 'required' && missing) {
75
75
  field = field ? `${field}.${missing}` : missing
76
76
  }
77
- const isSensitive = SENSITIVE_FIELD_RE.test(field) || SENSITIVE_FIELD_RE.test(missing)
77
+ // 정규식 1회 실행 (G1 M-2, ADR-214) field·missingProperty 를 합쳐 한 번만 검사한다. allErrors
78
+ // (ADR-193) 이후 400 1건당 detail × 2회 실행되던 비용 절반화. 매칭 의미는 동일(부분 일치 OR).
79
+ const isSensitive = SENSITIVE_FIELD_RE.test(missing ? `${field} ${missing}` : field)
78
80
  return {
79
81
  field,
80
82
  rule: e.keyword || 'unknown',
@@ -37,6 +37,9 @@ function passthroughT(key, defaultValue) {
37
37
  /** 요청당 ctx 를 캐싱하는 비열거 키. 글로벌 미들웨어와 라우트 핸들러가 같은 ctx 를 공유하도록 한다. */
38
38
  const HTTP_CTX_KEY = Symbol('mega.httpCtx')
39
39
 
40
+ /** 요청당 lazy ctx 프록시 캐싱 키 — 미들웨어·핸들러가 같은 프록시 객체를 받도록 한다 (ADR-214). */
41
+ const LAZY_CTX_KEY = Symbol('mega.lazyHttpCtx')
42
+
40
43
  /**
41
44
  * `ctx.services.<name>` lazy DI proxy (ADR-148) — 요청별 서비스 인스턴스화·캐시.
42
45
  *
@@ -252,6 +255,61 @@ export function getHttpCtx({ app, req, reply }) {
252
255
  const cached = /** @type {any} */ (req)[HTTP_CTX_KEY]
253
256
  if (cached) return cached
254
257
  const ctx = buildHttpCtx({ app, req, reply })
255
- Object.defineProperty(req, HTTP_CTX_KEY, { value: ctx, enumerable: false, configurable: true })
258
+ // 심볼 직접 대입 (G1 H-2, ADR-214) 심볼 키는 어차피 Object.keys/spread/JSON 안 잡히므로
259
+ // 비열거 defineProperty 의 실익이 없고, defineProperty 는 hidden class 전이를 유발해 17배 비싸다(실측).
260
+ ;/** @type {any} */ (req)[HTTP_CTX_KEY] = ctx
256
261
  return ctx
257
262
  }
263
+
264
+ /**
265
+ * **lazy** HTTP ctx — 실제 ctx 빌드를 첫 속성 접근 시점까지 미루는 요청당 프록시 (G1 H-1, ADR-214).
266
+ *
267
+ * hot path 단일 최대 프레임워크 비용이 "핸들러가 ctx 를 안 써도 매 요청 무조건 buildHttpCtx"
268
+ * (1,615 B + ≈0.7 µs/req, CPU 2.3% — audit-G1 실측)였다. 이 프록시는 생성 비용이 객체 1개 수준이고,
269
+ * 핸들러·미들웨어가 ctx 의 속성을 **처음 만질 때만** {@link getHttpCtx} 로 실 ctx 를 만든다(이후 캐시).
270
+ *
271
+ * 호환성: canonical 시그니처 `(req, reply, ctx)` 는 그대로 — 모든 핸들러가 여전히 3번째 인자를 받고,
272
+ * 참조하는 순간부터는 기존과 동일한 실 ctx 표면(ADR-134 요청당 공유 포함)이다. 프록시 자체도 요청당
273
+ * 1개로 캐시되어 미들웨어와 핸들러가 **동일 객체**(`===`)를 받는다. 모든 트랩이 실 ctx 로 위임되므로
274
+ * `in`/spread/`Object.keys`/getter·setter(`ctx.user=`) 동작이 보존된다.
275
+ *
276
+ * @param {object} args
277
+ * @param {import('./mega-app.js').MegaApp | null} args.app
278
+ * @param {import('fastify').FastifyRequest} args.req
279
+ * @param {import('fastify').FastifyReply} args.reply
280
+ * @returns {Record<string, any>}
281
+ */
282
+ export function getLazyHttpCtx({ app, req, reply }) {
283
+ const cached = /** @type {any} */ (req)[LAZY_CTX_KEY]
284
+ if (cached) return cached
285
+ /** 첫 트랩에서 실 ctx 를 만들고 이후 재사용 (getHttpCtx 가 req 단위 캐시를 겸한다). */
286
+ const real = () => getHttpCtx({ app, req, reply })
287
+ const proxy = new Proxy(
288
+ {},
289
+ {
290
+ get(_t, prop) {
291
+ return Reflect.get(real(), prop)
292
+ },
293
+ set(_t, prop, value) {
294
+ return Reflect.set(real(), prop, value)
295
+ },
296
+ has(_t, prop) {
297
+ return Reflect.has(real(), prop)
298
+ },
299
+ ownKeys() {
300
+ return Reflect.ownKeys(real())
301
+ },
302
+ getOwnPropertyDescriptor(_t, prop) {
303
+ return Object.getOwnPropertyDescriptor(real(), prop)
304
+ },
305
+ defineProperty(_t, prop, desc) {
306
+ return Reflect.defineProperty(real(), prop, desc)
307
+ },
308
+ deleteProperty(_t, prop) {
309
+ return Reflect.deleteProperty(real(), prop)
310
+ },
311
+ },
312
+ )
313
+ ;/** @type {any} */ (req)[LAZY_CTX_KEY] = proxy
314
+ return proxy
315
+ }
@@ -22,13 +22,20 @@ export const REPLY_START_SYMBOL = Symbol('mega.reply.start')
22
22
  export const ENVELOPE_MARK = Symbol('mega.envelope')
23
23
 
24
24
  /**
25
- * envelope 객체에 비열거 마킹을 부착한다.
25
+ * envelope 객체에 마킹을 부착한다.
26
+ *
27
+ * 심볼 **직접 대입** (G1 M-1, ADR-214) — `Object.defineProperty` 대비 17배 빠르고(136→8 ns/op 실측)
28
+ * allocation 도 작다. 심볼 키는 어차피 `JSON.stringify`/`Object.keys` 에 안 나오므로 와이어·열거
29
+ * 동작은 동일하다. 의미 차이는 하나 — spread 복사(`{...env}`)가 마크를 함께 복사한다. 복사본도
30
+ * "이미 envelope" 로 취급되는데, 이는 이중 wrap 방지(ADR-184)에 오히려 부합한다(복사 후 필드를
31
+ * 고쳐 재전송하는 패턴도 envelope 그대로 나간다 — 의도된 동작으로 확정, ADR-214).
32
+ *
26
33
  * @template {Record<string, any>} T
27
34
  * @param {T} env
28
35
  * @returns {T} 같은 객체 (마킹됨).
29
36
  */
30
37
  function markEnvelope(env) {
31
- Object.defineProperty(env, ENVELOPE_MARK, { value: true, enumerable: false })
38
+ ;/** @type {any} */ (env)[ENVELOPE_MARK] = true
32
39
  return env
33
40
  }
34
41
 
@@ -189,7 +189,9 @@ export class MegaHubLink {
189
189
  /**
190
190
  * hub 연결 + REGISTER 핸드셰이크. register_ok 수신 시 resolve.
191
191
  *
192
- * `retry` 옵션이 있으면 최초 연결도 지수 백오프로 재시도한다(ADR-098). 없으면 단발 시도.
192
+ * 초기 연결은 retry 유무와 무관하게 **단 1회** 시도한다. `retry` 옵션이 있으면 실패 시 백그라운드
193
+ * 재연결(지수 백오프)로 전환하고 reject 한다 — 성공 시 RECONNECTED, 소진 시 RECONNECT_FAILED emit.
194
+ * 호출부(boot)는 reject 를 warn 으로 받고 부팅을 계속한다(허브 다운이 부팅을 막지 않는 계약).
193
195
  *
194
196
  * @param {Object} [opts]
195
197
  * @param {number} [opts.connectTimeoutMs] - register_ok 대기 한도 override(M4). **이 값은 인스턴스에
@@ -214,19 +216,27 @@ export class MegaHubLink {
214
216
  throw err
215
217
  }
216
218
  }
217
- // retry 활성 — 최초 연결도 백오프 재시도. AbortError(인증 실패 등) 재시도하지 않는다.
218
- this._abort = new AbortController()
219
- return withRetry(() => this._connectOnce({ connectTimeoutMs: timeout }), {
220
- ...this._retry,
221
- signal: this._abort.signal,
222
- shouldRetry: retryUnlessFatal,
223
- onFailedAttempt: (ctx) => {
224
- this._log?.warn?.(
225
- { bridgeId: this._bridgeId, attempt: ctx.attemptNumber, retriesLeft: ctx.retriesLeft },
226
- 'hub-link connect attempt failedbacking off',
227
- )
228
- },
229
- })
219
+ // retry 활성 — 초기 연결은 **단 1회**만 시도하고, 실패하면 백그라운드 재연결로 전환한 뒤 throw 한다.
220
+ // 예전엔 withRetry 가 최초 연결까지 백오프 전체를 await 해 호출부(boot 의 cluster-transport step)
221
+ // 단위로 블로킹됐다(crud retry 기준 약 4.7분) — "허브 다운이어도 boot 를 막지 않는다" 는 호출부
222
+ // 계약과 정반대 동작. 이제 호출부는 즉시 실패를 보고(warn) 부팅을 계속하고, 백그라운드 _reconnect 가
223
+ // 같은 retry 백오프로 재시도한다 — 성공 시 RECONNECTED 이벤트가 presence 재동기화를 트리거하고,
224
+ // 소진 시 RECONNECT_FAILED 를 emit 한다. 치명 에러(인증 거부 등 fatal)는 재시도 무의미라 그대로 전파.
225
+ try {
226
+ return await this._connectOnce({ connectTimeoutMs: timeout })
227
+ } catch (err) {
228
+ // 레거시 hub 폴백(1회)protocolVersion 필드 거부 판정 후 v1 register 로 즉시 재시도.
229
+ if (err instanceof Error && err.message === 'hub.legacy_register_fallback') {
230
+ try {
231
+ return await this._connectOnce({ connectTimeoutMs: timeout })
232
+ } catch (err2) {
233
+ if (retryUnlessFatal({ error: err2 })) void this._reconnect()
234
+ throw err2
235
+ }
236
+ }
237
+ if (retryUnlessFatal({ error: err })) void this._reconnect()
238
+ throw err
239
+ }
230
240
  }
231
241
 
232
242
  /**
package/src/core/index.js CHANGED
@@ -16,7 +16,7 @@ export { buildErrorHandler } from './error-mapper.js'
16
16
  export { ajvErrorToValidationError, MAX_VALIDATION_DETAILS } from './ajv-mapper.js'
17
17
  export { loadAndValidateConfig } from './config-loader.js'
18
18
  // 요청 ctx 빌더 — db/cache/bus 접근자 배선 (ADR-102)
19
- export { buildHttpCtx, getHttpCtx, buildAdapterAccessors } from './ctx-builder.js'
19
+ export { buildHttpCtx, getHttpCtx, getLazyHttpCtx, buildAdapterAccessors } from './ctx-builder.js'
20
20
  // WS envelope + 컨트롤러 베이스 (ADR-015 / ADR-074)
21
21
  export {
22
22
  createWsMessage,