mega-framework 0.1.7 → 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 (76) 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/locales/server/en.json +12 -1
  6. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  7. package/sample/crud/mega.config.js +7 -0
  8. package/sample/crud/package.json +2 -2
  9. package/sample/crud/scripts/start-ws-hub.sh +18 -4
  10. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/src/adapters/adapter-options.js +14 -3
  12. package/src/adapters/file-adapter.js +9 -5
  13. package/src/adapters/file-session-adapter.js +4 -3
  14. package/src/adapters/maria-adapter.js +7 -4
  15. package/src/adapters/mega-cache-adapter.js +83 -6
  16. package/src/adapters/mega-db-adapter.js +4 -1
  17. package/src/adapters/mongo-adapter.js +21 -7
  18. package/src/adapters/postgres-adapter.js +8 -4
  19. package/src/adapters/redis-adapter.js +7 -3
  20. package/src/adapters/sqlite-adapter.js +6 -2
  21. package/src/cli/commands/console-cmd.js +3 -1
  22. package/src/cli/commands/scaffold.js +38 -2
  23. package/src/cli/generators/index.js +58 -1
  24. package/src/cli/index.js +88 -59
  25. package/src/cli/watch.js +188 -0
  26. package/src/core/ajv-mapper.js +3 -1
  27. package/src/core/ctx-builder.js +59 -1
  28. package/src/core/envelope.js +9 -2
  29. package/src/core/hub-link.js +24 -14
  30. package/src/core/index.js +1 -1
  31. package/src/core/mega-app.js +55 -45
  32. package/src/core/pipeline.js +8 -6
  33. package/src/core/scope-registry.js +1 -0
  34. package/src/core/security.js +3 -3
  35. package/src/core/session-store.js +14 -1
  36. package/src/core/ws-presence.js +17 -5
  37. package/src/core/ws-roster.js +49 -10
  38. package/src/core/ws-upgrade.js +105 -0
  39. package/src/lib/mega-circuit-breaker.js +5 -3
  40. package/src/lib/mega-health.js +10 -0
  41. package/src/lib/mega-job-queue.js +53 -13
  42. package/src/lib/mega-job.js +8 -1
  43. package/src/lib/mega-metrics.js +28 -1
  44. package/src/lib/mega-plugin.js +2 -2
  45. package/src/lib/mega-worker.js +28 -5
  46. package/src/lib/ws-hub.js +90 -9
  47. package/templates/adr/code.tpl +23 -0
  48. package/types/adapters/adapter-options.d.ts +2 -0
  49. package/types/adapters/file-adapter.d.ts +12 -1
  50. package/types/adapters/file-session-adapter.d.ts +4 -2
  51. package/types/adapters/maria-adapter.d.ts +5 -3
  52. package/types/adapters/mega-cache-adapter.d.ts +27 -1
  53. package/types/adapters/mega-db-adapter.d.ts +4 -1
  54. package/types/adapters/mongo-adapter.d.ts +13 -2
  55. package/types/adapters/postgres-adapter.d.ts +4 -2
  56. package/types/adapters/redis-adapter.d.ts +8 -0
  57. package/types/adapters/sqlite-adapter.d.ts +8 -2
  58. package/types/cli/generators/index.d.ts +11 -1
  59. package/types/cli/index.d.ts +12 -27
  60. package/types/cli/watch.d.ts +59 -0
  61. package/types/core/ctx-builder.d.ts +23 -0
  62. package/types/core/hub-link.d.ts +3 -1
  63. package/types/core/index.d.ts +1 -1
  64. package/types/core/mega-app.d.ts +1 -1
  65. package/types/core/pipeline.d.ts +2 -1
  66. package/types/core/security.d.ts +3 -3
  67. package/types/core/session-store.d.ts +7 -0
  68. package/types/core/ws-roster.d.ts +13 -1
  69. package/types/core/ws-upgrade.d.ts +29 -0
  70. package/types/lib/mega-circuit-breaker.d.ts +4 -2
  71. package/types/lib/mega-health.d.ts +7 -0
  72. package/types/lib/mega-job-queue.d.ts +16 -4
  73. package/types/lib/mega-job.d.ts +8 -1
  74. package/types/lib/mega-plugin.d.ts +1 -1
  75. package/types/lib/mega-worker.d.ts +3 -1
  76. package/types/lib/ws-hub.d.ts +27 -2
@@ -9,7 +9,6 @@
9
9
  * @module cli/commands/console-cmd
10
10
  */
11
11
  import repl from 'node:repl'
12
- import { prepareRuntime } from '../../core/boot.js'
13
12
  import { MegaShutdown } from '../../lib/mega-shutdown.js'
14
13
 
15
14
  /**
@@ -38,6 +37,9 @@ export async function startConsole(
38
37
  projectRoot,
39
38
  { logger, replFactory = defaultReplFactory, out = console.log, shutdown = () => MegaShutdown.now(), setupSignals = (opts) => MegaShutdown.setupSignals(opts) } = {},
40
39
  ) {
40
+ // boot 그래프(fastify·OTel·pino 등)는 콘솔 기동 시점에만 로드 — scaffold.js 가 본 모듈을 정적
41
+ // import 하므로, 여기서 정적 import 하면 `mega help`/`g` 까지 전체 그래프를 지불하게 된다.
42
+ const { prepareRuntime } = await import('../../core/boot.js')
41
43
  const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
42
44
  out('mega: console ready — globals: ctx, config, mega')
43
45
  // prepareRuntime 이 어댑터/워커/wsHub 를 connect 하고 각자 MegaShutdown hook 을 자기등록한다. 정리 없이
@@ -13,7 +13,9 @@ import { Command } from 'commander'
13
13
  import { existsSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import { pathToFileURL } from 'node:url'
16
- import { generate, generateFromScaffoldDef, GENERATOR_KINDS } from '../generators/index.js'
16
+ import { execFile } from 'node:child_process'
17
+ import { promisify } from 'node:util'
18
+ import { generate, generateFromScaffoldDef, nextAdrNumber, GENERATOR_KINDS } from '../generators/index.js'
17
19
  import { scaffoldProject } from './new.js'
18
20
  import { runRoutesCommand } from './routes.js'
19
21
  import { runTestCommand } from './test-cmd.js'
@@ -31,6 +33,36 @@ function reportFiles(out, r, root) {
31
33
  for (const f of r.skipped) out(` skip ${f.startsWith(root) ? f.slice(root.length + 1) : f} (exists — use --force)`)
32
34
  }
33
35
 
36
+ const execFileAsync = promisify(execFile)
37
+
38
+ /**
39
+ * `g adr` 의 다음 번호를 **원격 포함**으로 해석한다(ADR-218 — 병렬 task 번호 충돌 회피).
40
+ * `git fetch origin` 후 `origin/main` 의 `docs/adr/` 파일명에서 번호를 모아 로컬 스캔
41
+ * ({@link nextAdrNumber})과 합산한다 — 형제 task 가 방금 push 한 ADR 이 로컬 작업트리에 없어도
42
+ * 번호가 건너뛰어진다. git 부재/오프라인/비-repo 는 로컬 스캔만으로 폴백(경고 1줄 — 스캐폴드는
43
+ * 어디서든 동작해야 하므로 fail 아님).
44
+ *
45
+ * @param {string} projectRoot
46
+ * @param {(msg: string) => void} out
47
+ * @returns {Promise<number>}
48
+ */
49
+ async function resolveAdrNumberWithRemote(projectRoot, out) {
50
+ /** @type {number[]} */
51
+ const remote = []
52
+ try {
53
+ await execFileAsync('git', ['fetch', 'origin', '--quiet'], { cwd: projectRoot })
54
+ const { stdout } = await execFileAsync('git', ['ls-tree', '--name-only', 'origin/main', 'docs/adr/'], { cwd: projectRoot })
55
+ for (const line of stdout.split('\n')) {
56
+ const m = line.match(/(\d{1,4})-[^/]+\.md$/)
57
+ if (m) remote.push(Number(m[1]))
58
+ }
59
+ } catch (err) {
60
+ // 오프라인/비-repo/origin 부재 — 로컬 스캔만으로 진행하되 충돌 가능성을 알린다(silent 금지).
61
+ out(`mega: 원격 ADR 번호 확인 실패(${/** @type {any} */ (err).message?.split('\n')[0] ?? err}) — 로컬 기준으로 번호를 할당합니다.`)
62
+ }
63
+ return nextAdrNumber(projectRoot, remote)
64
+ }
65
+
34
66
  /**
35
67
  * `g model --adapter <key>` 의 adapter 키/driver 해석 — mega.config.js 의 services.databases 를
36
68
  * best-effort 로 읽는다(스캐폴드는 config 가 아직 없는 초기 프로젝트에서도 돌아야 하므로 로드
@@ -109,10 +141,14 @@ export function registerScaffoldCommands(program, { out, projectRoot, logger, re
109
141
  /** @type {{ key: string, driver: string | undefined }} */
110
142
  let modelAdapter = { key: 'primary', driver: undefined }
111
143
  if (kind === 'model') modelAdapter = await resolveModelAdapter(projectRoot, opts.adapter, out)
144
+ // adr 은 번호를 원격 포함으로 해석한다(병렬 task 충돌 회피, ADR-218).
145
+ /** @type {number | undefined} */
146
+ let adrNumber
147
+ if (kind === 'adr') adrNumber = await resolveAdrNumberWithRemote(projectRoot, out)
112
148
  r = generate(
113
149
  kind,
114
150
  name,
115
- { app: opts.app, version: opts.version, kind: opts.kind, lng: opts.lng, force: opts.force === true, adapter: modelAdapter.key, adapterDriver: modelAdapter.driver },
151
+ { app: opts.app, version: opts.version, kind: opts.kind, lng: opts.lng, force: opts.force === true, adapter: modelAdapter.key, adapterDriver: modelAdapter.driver, adrNumber },
116
152
  projectRoot,
117
153
  )
118
154
  } else {
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * @module cli/generators
15
15
  */
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
17
17
  import { dirname, join, relative, resolve, sep } from 'node:path'
18
18
  import { fileURLToPath } from 'node:url'
19
19
  import { nameVariants, renderTemplate } from '../template-engine.js'
@@ -36,6 +36,7 @@ export const GENERATOR_KINDS = /** @type {const} */ ([
36
36
  'locale',
37
37
  'adapter',
38
38
  'migration',
39
+ 'adr',
39
40
  ])
40
41
 
41
42
  /**
@@ -185,11 +186,67 @@ export function planArtifacts(kind, rawName, opts, projectRoot) {
185
186
  case 'app':
186
187
  return planApp(v, projectRoot, base)
187
188
 
189
+ case 'adr':
190
+ return planAdr(v, opts, projectRoot)
191
+
188
192
  default:
189
193
  throw new Error(`Unknown generator kind '${kind}'. Supported: ${GENERATOR_KINDS.join(', ')}.`)
190
194
  }
191
195
  }
192
196
 
197
+ /**
198
+ * 다음 ADR 번호를 해석한다 — `docs/adr/NNNN-*.md` 파일명 + 레거시 `docs/09` 헤딩(`### ADR-N:`) +
199
+ * 호출측이 모은 추가 번호(예: `git ls-tree origin/main` 의 원격 파일 — 병렬 task 충돌 회피)의
200
+ * 최댓값 + 1. 아무 ADR 도 없으면 1.
201
+ *
202
+ * @param {string} projectRoot
203
+ * @param {number[]} [extraNumbers] - 로컬 밖에서 관측한 번호(원격 스캔 등).
204
+ * @returns {number}
205
+ */
206
+ export function nextAdrNumber(projectRoot, extraNumbers = []) {
207
+ let max = 0
208
+ const adrDir = join(projectRoot, 'docs/adr')
209
+ if (existsSync(adrDir)) {
210
+ for (const f of readdirSync(adrDir)) {
211
+ const m = f.match(/^(\d{1,4})-.+\.md$/)
212
+ if (m) max = Math.max(max, Number(m[1]))
213
+ }
214
+ }
215
+ const legacy = join(projectRoot, 'docs/09-decisions-and-open-questions.md')
216
+ if (existsSync(legacy)) {
217
+ for (const m of readFileSync(legacy, 'utf8').matchAll(/^### ADR-(\d+)/gm)) {
218
+ max = Math.max(max, Number(m[1]))
219
+ }
220
+ }
221
+ for (const n of extraNumbers) {
222
+ if (Number.isInteger(n)) max = Math.max(max, n)
223
+ }
224
+ return max + 1
225
+ }
226
+
227
+ /** adr — `docs/adr/NNNN-<name>.md` 1개(코드/테스트 쌍 아님 — 프로젝트 결정 기록 문서, ADR-218).
228
+ * 번호는 opts.adrNumber(호출측이 원격 포함 해석) 우선, 미지정 시 로컬 스캔({@link nextAdrNumber}).
229
+ * @param {Variants} v @param {Record<string, any>} opts @param {string} projectRoot
230
+ * @returns {Artifact[]} */
231
+ function planAdr(v, opts, projectRoot) {
232
+ const number = Number.isInteger(opts.adrNumber) && opts.adrNumber > 0 ? opts.adrNumber : nextAdrNumber(projectRoot)
233
+ const padded = String(number).padStart(4, '0')
234
+ const d = new Date()
235
+ const p = (/** @type {number} */ n) => String(n).padStart(2, '0')
236
+ const vars = {
237
+ number: String(number),
238
+ title: v.words.join(' '),
239
+ date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
240
+ }
241
+ return [
242
+ {
243
+ outAbs: join(projectRoot, `docs/adr/${padded}-${v.kebab}.md`),
244
+ role: 'code',
245
+ content: renderTemplate(readTpl('adr', 'code.tpl'), vars),
246
+ },
247
+ ]
248
+ }
249
+
193
250
  /**
194
251
  * @typedef {{ kebab: string, pascal: string, camel: string, snake: string, words: string[] }} Variants
195
252
  * @typedef {Record<string, string>} BaseVars
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',