mega-framework 0.1.9 → 0.1.11

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 (90) hide show
  1. package/README.md +14 -4
  2. package/package.json +23 -21
  3. package/sample/crud/.env +10 -2
  4. package/sample/crud/.env.example +8 -0
  5. package/sample/crud/apps/main/controllers/bus-controller.js +122 -0
  6. package/sample/crud/apps/main/controllers/jobs-controller.js +22 -2
  7. package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
  8. package/sample/crud/apps/main/jobs/email-job.js +37 -2
  9. package/sample/crud/apps/main/locales/server/en.json +36 -1
  10. package/sample/crud/apps/main/locales/server/ko.json +36 -1
  11. package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
  12. package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
  13. package/sample/crud/apps/main/routes/bus.js +43 -0
  14. package/sample/crud/apps/main/routes/lock.js +35 -0
  15. package/sample/crud/apps/main/services/jobs-demo-service.js +22 -15
  16. package/sample/crud/apps/main/views/bus/index.ejs +80 -0
  17. package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
  18. package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
  19. package/sample/crud/apps/main/views/lock/index.ejs +99 -0
  20. package/sample/crud/docs/guide/03-service-model-db.md +48 -0
  21. package/sample/crud/docs/guide/05-scheduler-job-worker.md +29 -2
  22. package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
  23. package/sample/crud/docs/guide/10-multi-app.md +74 -0
  24. package/sample/crud/mega.config.js +32 -0
  25. package/sample/crud/package.json +3 -2
  26. package/sample/multi/.env +16 -0
  27. package/sample/multi/.env.example +17 -0
  28. package/sample/multi/README.md +54 -0
  29. package/sample/multi/apps/admin/app.config.js +24 -0
  30. package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
  31. package/sample/multi/apps/admin/public/js/admin.js +31 -0
  32. package/sample/multi/apps/admin/routes/pages.js +11 -0
  33. package/sample/multi/apps/admin/views/index.ejs +33 -0
  34. package/sample/multi/apps/web/app.config.js +30 -0
  35. package/sample/multi/apps/web/controllers/web-controller.js +45 -0
  36. package/sample/multi/apps/web/public/js/web.js +24 -0
  37. package/sample/multi/apps/web/routes/pages.js +13 -0
  38. package/sample/multi/apps/web/views/index.ejs +51 -0
  39. package/sample/multi/mega.config.js +42 -0
  40. package/sample/multi/package.json +20 -0
  41. package/sample/simple/package.json +2 -2
  42. package/src/adapters/nats-adapter.js +39 -44
  43. package/src/adapters/nats-codec.js +38 -0
  44. package/src/cli/commands/scaffold.js +1 -0
  45. package/src/cli/index.js +50 -1
  46. package/src/core/app-registry.js +69 -0
  47. package/src/core/boot.js +99 -0
  48. package/src/core/bus/cluster-bus.js +190 -0
  49. package/src/core/bus/contract.js +123 -0
  50. package/src/core/bus/index.js +285 -0
  51. package/src/core/bus/memory-bus.js +103 -0
  52. package/src/core/bus/nats-bus.js +203 -0
  53. package/src/core/config-validator.js +118 -1
  54. package/src/core/ctx-builder.js +14 -1
  55. package/src/core/index.js +2 -0
  56. package/src/core/lock/cluster-lock.js +174 -0
  57. package/src/core/lock/contract.js +123 -0
  58. package/src/core/lock/fifo-waitlist.js +93 -0
  59. package/src/core/lock/index.js +292 -0
  60. package/src/core/lock/memory-lock.js +162 -0
  61. package/src/core/lock/redis-lock.js +276 -0
  62. package/src/core/mega-app.js +29 -0
  63. package/src/core/migration/generate.js +1 -1
  64. package/src/core/migration/journal.js +1 -1
  65. package/src/core/scope-registry.js +9 -0
  66. package/src/eslint-plugin/no-direct-model-import.js +2 -2
  67. package/src/index.js +2 -0
  68. package/src/lib/mega-job-queue.js +71 -47
  69. package/types/adapters/mega-adapter.d.ts +1 -1
  70. package/types/adapters/nats-adapter.d.ts +4 -4
  71. package/types/adapters/nats-codec.d.ts +13 -0
  72. package/types/adapters/redlock-adapter.d.ts +1 -1
  73. package/types/core/app-registry.d.ts +22 -0
  74. package/types/core/bus/cluster-bus.d.ts +45 -0
  75. package/types/core/bus/contract.d.ts +164 -0
  76. package/types/core/bus/index.d.ts +100 -0
  77. package/types/core/bus/memory-bus.d.ts +45 -0
  78. package/types/core/bus/nats-bus.d.ts +41 -0
  79. package/types/core/index.d.ts +1 -0
  80. package/types/core/lock/cluster-lock.d.ts +44 -0
  81. package/types/core/lock/contract.d.ts +181 -0
  82. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  83. package/types/core/lock/index.d.ts +96 -0
  84. package/types/core/lock/memory-lock.d.ts +58 -0
  85. package/types/core/lock/redis-lock.d.ts +43 -0
  86. package/types/core/mega-app.d.ts +10 -0
  87. package/types/core/scope-registry.d.ts +6 -0
  88. package/types/index.d.ts +1 -1
  89. package/types/lib/mega-job-queue.d.ts +27 -4
  90. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -1,10 +1,14 @@
1
1
  // @ts-check
2
2
  /**
3
- * MegaNatsAdapter — NATS 메시지 버스 어댑터 (`nats` 공식 driver 래퍼, ADR-112).
3
+ * MegaNatsAdapter — NATS 메시지 버스 어댑터 (`@nats-io/*` v3 driver 래퍼, ADR-112/225).
4
4
  *
5
5
  * **첫 bus 도메인 어댑터**(`MegaBusAdapter` 첫 구체). DB/cache 와 달리 pub/sub·req/reply·
6
6
  * queue group 메시징 인터페이스를 구현한다.
7
7
  *
8
+ * # nats v3 (`@nats-io/*`) — v2 `nats` 단일 패키지에서 분리됨 (ADR-225)
9
+ * core connect 는 `@nats-io/transport-node`, 타입은 `@nats-io/nats-core`. `JSONCodec()` 팩토리가
10
+ * 제거돼 본 어댑터는 {@link import('./nats-codec.js')} 의 공유 JSON 코덱을 쓴다.
11
+ *
8
12
  * # 표준 표면 (MegaBusAdapter 상속)
9
13
  * - `_connect()` — `connect({ servers, ...auth, ...options })` (driver 는 connect 시점 lazy import).
10
14
  * connect() 자체가 서버 응답까지 기다리므로 별도 ping 불필요(실패 시 throw=검증).
@@ -15,10 +19,10 @@
15
19
  * - `getStats()` — 베이스 stats + nats 특화(server/연결 stats: inMsgs/outMsgs/inBytes/outBytes).
16
20
  * - `publish/subscribe/request` + `enqueue/process` — 아래 인터페이스.
17
21
  *
18
- * # 직렬화 = JSONCodec
19
- * payload 는 NATS wire 에서 `Uint8Array` 다. 본 어댑터는 `JSONCodec` 인코드/디코드를 표준화해
20
- * 사용자는 JS 값을 그대로 publish/subscribe 한다(수신 측에서 같은 값으로 복원). `undefined` payload
21
- * 는 `null` 로 정규화(JSONCodec undefined 를 인코드하지 못함).
22
+ * # 직렬화 = 공유 JSON 코덱 (ADR-225 — v3 JSONCodec 제거)
23
+ * payload 는 NATS wire 에서 `Uint8Array` 다. 본 어댑터는 {@link import('./nats-codec.js')}
24
+ * `encodeJson`/`decodeJson` 로 인코드/디코드를 표준화해 사용자는 JS 값을 그대로 publish/subscribe
25
+ * 한다(수신 측에서 같은 값으로 복원). `undefined` payload 는 `null` 로 정규화(JSON undefined 미표현).
22
26
  *
23
27
  * # queue (job) 인터페이스 — 단순 publish + queue group (jetstream 미사용, ADR-112)
24
28
  * `enqueue(job, msg)` = 해당 subject 로 **단순 publish**. `process(job, handler)` = **queue group**
@@ -59,6 +63,7 @@
59
63
  import { MegaValidationError, MegaInternalError } from '../errors/http-errors.js'
60
64
  import { MegaBusAdapter } from './mega-bus-adapter.js'
61
65
  import { resolveConnection, assertPlainObject } from './adapter-options.js'
66
+ import { encodeJson, decodeJson } from './nats-codec.js'
62
67
  import * as Registry from './registry.js'
63
68
 
64
69
  /** NATS 기본 클라이언트 포트(discrete 모드에서 port 미지정 시). */
@@ -78,11 +83,9 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30000
78
83
  */
79
84
 
80
85
  export class MegaNatsAdapter extends MegaBusAdapter {
81
- /** @type {import('nats').NatsConnection | null} 연결된 NatsConnection (connect 후에만). */
86
+ /** @type {import('@nats-io/nats-core').NatsConnection | null} 연결된 NatsConnection (connect 후에만). */
82
87
  #nc = null
83
- /** @type {import('nats').Codec<any> | null} JSONCodec (connect 시 생성). */
84
- #codec = null
85
- /** @type {import('nats').ConnectionOptions} _connect 에서 `connect()` 에 넘길 옵션(생성자에서 고정). */
88
+ /** @type {import('@nats-io/nats-core').ConnectionOptions} _connect 에서 `connect()` 에 넘길 옵션(생성자에서 고정). */
86
89
  #connectOptions
87
90
 
88
91
  /**
@@ -107,7 +110,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
107
110
  const conn = resolveConnection(config, { driver: 'nats', dbConflictsWithUrl: false })
108
111
  assertPlainObject('options', config.options, { driver: 'nats' })
109
112
 
110
- /** @type {import('nats').ConnectionOptions} */
113
+ /** @type {import('@nats-io/nats-core').ConnectionOptions} */
111
114
  const connectOptions = {}
112
115
  // options(passthrough) 먼저 — 아래 servers/auth 가 항상 이긴다(연결 필수값 보호).
113
116
  if (config.options !== undefined) Object.assign(connectOptions, config.options)
@@ -135,10 +138,9 @@ export class MegaNatsAdapter extends MegaBusAdapter {
135
138
  * @returns {Promise<void>}
136
139
  */
137
140
  async _connect() {
138
- const { connect, JSONCodec } = await import('nats')
139
- const nc = await connect(this.#connectOptions)
140
- this.#nc = nc
141
- this.#codec = JSONCodec()
141
+ // v3: core connect `@nats-io/transport-node`(노드 전송) v2 `nats` 단일 패키지 대체(ADR-225).
142
+ const { connect } = await import('@nats-io/transport-node')
143
+ this.#nc = await connect(this.#connectOptions)
142
144
  }
143
145
 
144
146
  /**
@@ -151,7 +153,6 @@ export class MegaNatsAdapter extends MegaBusAdapter {
151
153
  if (this.#nc !== null) {
152
154
  const nc = this.#nc
153
155
  this.#nc = null
154
- this.#codec = null
155
156
  // 이미 닫혀 있으면(서버측 절단 등) drain 이 throw 할 수 있어 가드.
156
157
  if (!nc.isClosed()) await nc.drain()
157
158
  }
@@ -160,7 +161,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
160
161
  /**
161
162
  * raw NatsConnection handle (ADR-009). `connect()` 후에만 베이스 `native` getter 가 호출한다.
162
163
  * @protected
163
- * @returns {import('nats').NatsConnection}
164
+ * @returns {import('@nats-io/nats-core').NatsConnection}
164
165
  */
165
166
  _native() {
166
167
  if (this.#nc === null) {
@@ -193,7 +194,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
193
194
 
194
195
  /**
195
196
  * 누적 통계 + nats 특화(server + 연결 stats). 연결 전이면 server/stats 는 undefined.
196
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, server: string | undefined, nats: import('nats').Stats | undefined }}
197
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, server: string | undefined, nats: import('@nats-io/nats-core').Stats | undefined }}
197
198
  */
198
199
  getStats() {
199
200
  const nc = this.#nc
@@ -211,15 +212,15 @@ export class MegaNatsAdapter extends MegaBusAdapter {
211
212
  // ──────────────────────────────────────────────────────────────────────
212
213
 
213
214
  /**
214
- * fire-and-forget 발행 (ack X). payload 는 JSONCodec 인코드.
215
+ * fire-and-forget 발행 (ack X). payload 는 공유 JSON 코덱(encodeJson)으로 인코드.
215
216
  * @param {string} subject
216
217
  * @param {any} payload
217
218
  * @returns {Promise<void>}
218
219
  */
219
220
  async publish(subject, payload) {
220
221
  return this._instrument('publish', { subject }, async () => {
221
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
222
- nc.publish(subject, this.#encode(payload))
222
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
223
+ nc.publish(subject, encodeJson(payload))
223
224
  })
224
225
  }
225
226
 
@@ -233,7 +234,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
233
234
  */
234
235
  async subscribe(subject, handler) {
235
236
  return this._instrument('subscribe', { subject }, async () => {
236
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
237
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
237
238
  const sub = nc.subscribe(subject, {
238
239
  callback: (err, msg) => this.#dispatch('subscribe', subject, handler, err, msg),
239
240
  })
@@ -254,20 +255,22 @@ export class MegaNatsAdapter extends MegaBusAdapter {
254
255
  */
255
256
  async request(subject, payload, { timeout = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
256
257
  return this._instrument('request', { subject, timeout }, async () => {
257
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
258
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
258
259
  try {
259
- const reply = await nc.request(subject, this.#encode(payload), { timeout })
260
- return /** @type {import('nats').Codec<any>} */ (this.#codec).decode(reply.data)
260
+ const reply = await nc.request(subject, encodeJson(payload), { timeout })
261
+ return decodeJson(reply.data)
261
262
  } catch (err) {
262
- // NATS 타임아웃/무응답을 명시 에러 코드로 변환(silent 무시 X). 에러는 원본 전파.
263
- const code = /** @type {any} */ (err)?.code
264
- if (code === 'TIMEOUT') {
263
+ // v3(ADR-225): 타임아웃/무응답이 문자열 code('TIMEOUT'/'503')에서 **에러 클래스**로 바뀌었다.
264
+ // 클래스는 cold path catch 에서 lazy import(모듈은 _connect 가 이미 로드 — 비용 0). 명시
265
+ // 에러 코드로 변환(silent 무시 X). RequestError 는 no-responders 를 isNoResponders() 로 알린다.
266
+ const { TimeoutError, NoRespondersError, RequestError } = await import('@nats-io/nats-core')
267
+ if (err instanceof TimeoutError) {
265
268
  throw new MegaInternalError('bus.request_timeout', `nats request("${subject}") timed out after ${timeout}ms.`, {
266
269
  details: { subject, timeout },
267
270
  cause: err,
268
271
  })
269
272
  }
270
- if (code === '503') {
273
+ if (err instanceof NoRespondersError || (err instanceof RequestError && err.isNoResponders())) {
271
274
  throw new MegaInternalError('bus.no_responders', `nats request("${subject}"): no responders subscribed to the subject.`, {
272
275
  details: { subject },
273
276
  cause: err,
@@ -286,8 +289,8 @@ export class MegaNatsAdapter extends MegaBusAdapter {
286
289
  */
287
290
  async enqueue(jobName, payload) {
288
291
  return this._instrument('enqueue', { jobName }, async () => {
289
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
290
- nc.publish(jobName, this.#encode(payload))
292
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
293
+ nc.publish(jobName, encodeJson(payload))
291
294
  })
292
295
  }
293
296
 
@@ -313,7 +316,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
313
316
  */
314
317
  async process(jobName, handler, { queue = jobName } = {}) {
315
318
  return this._instrument('process', { jobName, queue }, async () => {
316
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
319
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
317
320
  nc.subscribe(jobName, {
318
321
  queue,
319
322
  callback: (err, msg) => this.#dispatch('process', jobName, (m) => handler(m), err, msg),
@@ -321,23 +324,15 @@ export class MegaNatsAdapter extends MegaBusAdapter {
321
324
  })
322
325
  }
323
326
 
324
- /**
325
- * payload → Uint8Array (JSONCodec). undefined 는 null 로 정규화(JSONCodec 가 undefined 미지원).
326
- * @param {any} payload @returns {Uint8Array}
327
- */
328
- #encode(payload) {
329
- return /** @type {import('nats').Codec<any>} */ (this.#codec).encode(payload === undefined ? null : payload)
330
- }
331
-
332
327
  /**
333
328
  * 구독/잡 콜백 공통 디스패처 — 에러·디코드·handler 호출을 표면화한다(silent 금지).
334
- * 구독 에러(NatsError)·디코드 실패·handler throw 를 모두 `console.error` 로 드러낸다.
329
+ * 구독 에러·디코드 실패·handler throw 를 모두 `console.error` 로 드러낸다.
335
330
  *
336
331
  * @param {'subscribe' | 'process'} kind
337
332
  * @param {string} subject
338
333
  * @param {(msg: any, replyFn?: (payload: any) => void) => any} handler
339
- * @param {import('nats').NatsError | null} err
340
- * @param {import('nats').Msg} msg
334
+ * @param {Error | null} err
335
+ * @param {import('@nats-io/nats-core').Msg} msg
341
336
  * @returns {void}
342
337
  */
343
338
  #dispatch(kind, subject, handler, err, msg) {
@@ -348,14 +343,14 @@ export class MegaNatsAdapter extends MegaBusAdapter {
348
343
  }
349
344
  let decoded
350
345
  try {
351
- decoded = /** @type {import('nats').Codec<any>} */ (this.#codec).decode(msg.data)
346
+ decoded = decodeJson(msg.data)
352
347
  } catch (decodeErr) {
353
348
  console.error(`[MegaNatsAdapter] ${kind}("${subject}") payload decode failed:`, decodeErr)
354
349
  return
355
350
  }
356
351
  // reply subject 가 있으면 replyFn 제공(request 응답용). subscribe 만 해당 — process 는 단방향.
357
352
  const replyFn =
358
- kind === 'subscribe' && msg.reply ? /** @param {any} p */ (p) => msg.respond(this.#encode(p)) : undefined
353
+ kind === 'subscribe' && msg.reply ? /** @param {any} p */ (p) => msg.respond(encodeJson(p)) : undefined
359
354
  try {
360
355
  const out = handler(decoded, replyFn)
361
356
  // handler 가 async 면 reject 도 표면화(떠다니는 promise 가 silent 실패되지 않게).
@@ -0,0 +1,38 @@
1
+ // @ts-check
2
+ /**
3
+ * NATS JSON wire 코덱 — `@nats-io/*` v3 에서 제거된 `JSONCodec()` 대체 (ADR-225).
4
+ *
5
+ * v2 `nats` 패키지는 `JSONCodec()` 팩토리로 JS 값 ↔ `Uint8Array` 변환을 제공했으나, v3
6
+ * (`@nats-io/nats-core`)에서 코덱 팩토리가 제거되고 메시지에 `.json()`/`.string()` 편의 메서드만
7
+ * 남았다. 본 모듈은 어댑터·잡 큐가 wire 에 싣는 payload 의 JSON 직렬화를 **한 곳에서** 정의해
8
+ * (`MegaNatsAdapter` publish ↔ 소비자 decode 라운드트립 일관성), v2 `JSONCodec` 의 의미를 보존한다:
9
+ * - encode: `undefined` 는 `null` 로 정규화(JSON 은 `undefined` 를 표현 못 함) 후 `JSON.stringify`.
10
+ * - decode: 빈 payload(길이 0)는 `null` 로(빈 발행을 graceful 처리). 그 외는 `JSON.parse`.
11
+ *
12
+ * TextEncoder/TextDecoder 는 Node 전역(웹 표준)이라 신규 의존성 0.
13
+ *
14
+ * @module adapters/nats-codec
15
+ */
16
+
17
+ const TE = new TextEncoder()
18
+ const TD = new TextDecoder()
19
+
20
+ /**
21
+ * JS 값 → NATS wire 바이트(JSON). `undefined` 는 `null` 로 정규화한다.
22
+ * @param {any} value
23
+ * @returns {Uint8Array}
24
+ */
25
+ export function encodeJson(value) {
26
+ return TE.encode(JSON.stringify(value === undefined ? null : value))
27
+ }
28
+
29
+ /**
30
+ * NATS wire 바이트(JSON) → JS 값. 빈 payload(길이 0)는 `null`. 파싱 실패는 throw(호출부가 처리).
31
+ * @param {Uint8Array} data
32
+ * @returns {any}
33
+ * @throws {SyntaxError} JSON 파싱 실패 시(silent 금지 — poison 메시지 감지에 사용).
34
+ */
35
+ export function decodeJson(data) {
36
+ if (!data || data.byteLength === 0) return null
37
+ return JSON.parse(TD.decode(data))
38
+ }
@@ -162,6 +162,7 @@ export function registerScaffoldCommands(program, { out, projectRoot, logger, re
162
162
  throw new Error(
163
163
  `Unknown generator '${kind}'. Supported: ${GENERATOR_KINDS.join(', ')}. ` +
164
164
  `(플러그인 generator 확인 실패: ${/** @type {any} */ (e).message ?? e})`,
165
+ { cause: e },
165
166
  )
166
167
  }
167
168
  if (def === undefined) {
package/src/cli/index.js CHANGED
@@ -396,11 +396,13 @@ async function runStart(opts, { argv, out, projectRoot, logger, spawn, selfPath
396
396
  }
397
397
 
398
398
  // 런타임 그래프 lazy 로드 — 부팅 명령에서만 프레임워크 전체를 지불한다(모듈 상단 주석).
399
- const [{ bootApp }, { MegaCluster }, { installPrimaryAggregator, installWorkerResponder }, { buildLogger }] =
399
+ const [{ bootApp }, { MegaCluster }, { installPrimaryAggregator, installWorkerResponder }, { installClusterLockMaster }, { installClusterBusMaster }, { buildLogger }] =
400
400
  await Promise.all([
401
401
  import('../core/boot.js'),
402
402
  import('../core/mega-cluster.js'),
403
403
  import('../core/cluster-metrics.js'),
404
+ import('../core/lock/cluster-lock.js'),
405
+ import('../core/bus/cluster-bus.js'),
404
406
  import('../lib/mega-logger.js'),
405
407
  ])
406
408
 
@@ -444,6 +446,12 @@ async function runStart(opts, { argv, out, projectRoot, logger, spawn, selfPath
444
446
  if (mega.isPrimary()) {
445
447
  // 마스터에 메트릭 집계기 설치(ADR-163) — 워커의 /metrics 요청을 받아 전 워커 합산해 회신.
446
448
  installPrimaryAggregator()
449
+ // 마스터에 분산 락 responder 설치(ADR-226) — cluster lock driver 워커들의 IPC 요청을 한 곳에서 직렬화.
450
+ // redis 없이 클러스터로만 돌 때 워커 간 상호배제를 마스터의 in-memory 락으로 제공한다(단일 노드 한정).
451
+ installClusterLockMaster()
452
+ // 마스터에 메시지 버스 라우터 설치(ADR-227) — cluster bus driver 워커들의 pub/sub IPC 를 fan-out.
453
+ // NATS 없이 클러스터로만 돌 때 워커 간 메시지 전달을 마스터 라우터로 제공한다(단일 노드 한정).
454
+ installClusterBusMaster()
447
455
  announce(masterLogger, `cluster master ${process.pid} forked ${workers} worker(s)`)
448
456
  }
449
457
  return 0
@@ -691,6 +699,39 @@ async function wireHostLogger(global, injectedLogger) {
691
699
  return appLogger
692
700
  }
693
701
 
702
+ /**
703
+ * 잡 워커의 큐 길목 이벤트를 호스트 로거로 흘린다(ADR-223). `MegaJobWorker` 는 순수 런타임이라 logger 를
704
+ * 모르고 이벤트만 재방출하므로, 호스트가 구독하지 않으면 production 에서 잡 실패·DLQ 격리가 로그에 안 남는다.
705
+ * 레벨: `fail`=error(최종 실패), `dlq`=warn(격리됨), `retry`=warn(재시도), `start`/`done`=debug(prod silent).
706
+ * `dispatch`(enqueue)는 producer(웹) 측 이벤트라 워커 호스트에선 발화하지 않아 구독하지 않는다. 핵심 식별
707
+ * 필드만 박는다(전체 페이로드·결과 금지, P5). `log` 가 없으면(로거 미구성) 옵셔널 체이닝으로 no-op.
708
+ * @param {import('../lib/mega-job-worker.js').MegaJobWorker} worker
709
+ * @param {{ debug?: Function, warn?: Function, error?: Function }|null|undefined} log
710
+ * @returns {void}
711
+ */
712
+ function subscribeJobLogging(worker, log) {
713
+ worker.on('fail', (e) => log?.error?.({ err: e?.error, subject: e?.subject, seq: e?.seq, phase: e?.phase }, 'job failed'))
714
+ worker.on('dlq', (e) => log?.warn?.({ subject: e?.subject, dlqSubject: e?.dlqSubject, seq: e?.seq }, 'job routed to DLQ'))
715
+ worker.on('retry', (e) => log?.warn?.({ err: e?.error, subject: e?.subject, seq: e?.seq, attempt: e?.attempt, retriesLeft: e?.retriesLeft }, 'job retry'))
716
+ worker.on('start', (e) => log?.debug?.({ subject: e?.subject, seq: e?.seq }, 'job start'))
717
+ worker.on('done', (e) => log?.debug?.({ subject: e?.subject, seq: e?.seq }, 'job done'))
718
+ }
719
+
720
+ /**
721
+ * 스케줄러의 길목 이벤트를 호스트 로거로 흘린다(ADR-223 — 잡 워커와 같은 갭). `MegaScheduler` 도 이벤트
722
+ * (run/skip/done/fail)만 노출하므로 호스트가 구독해 로그로 남긴다. 레벨: `fail`=error, 나머지는 debug
723
+ * (특히 `skip`=락 미획득은 클러스터 정상 동작이라 debug). `key`(락 키)는 클러스터 leader 추적에 유용.
724
+ * @param {import('../lib/mega-schedule.js').MegaScheduler} scheduler
725
+ * @param {{ debug?: Function, warn?: Function, error?: Function }|null|undefined} log
726
+ * @returns {void}
727
+ */
728
+ function subscribeScheduleLogging(scheduler, log) {
729
+ scheduler.on('fail', (e) => log?.error?.({ err: e?.error, name: e?.name, key: e?.key, phase: e?.phase }, 'schedule failed'))
730
+ scheduler.on('skip', (e) => log?.debug?.({ name: e?.name, key: e?.key, reason: e?.reason }, 'schedule skipped'))
731
+ scheduler.on('run', (e) => log?.debug?.({ name: e?.name, key: e?.key }, 'schedule run'))
732
+ scheduler.on('done', (e) => log?.debug?.({ name: e?.name, key: e?.key }, 'schedule done'))
733
+ }
734
+
694
735
  /**
695
736
  * `mega worker` 호스트 골격 — config + 어댑터 connect + ctx + `MegaJobWorker` 인스턴스 + graceful.
696
737
  * 등록 소스 = `config.jobs` + 플러그인 `mega.jobs.register` 분(`collectRegistrations`, ADR-123).
@@ -712,6 +753,11 @@ export async function runWorkerHost(projectRoot, logger) {
712
753
  // 옵트인 OFF 면 subscribeJobs 는 no-op() 을 반환한다. start 전에 구독해 enqueue(dispatch)부터 잡는다. 구독은
713
754
  // MegaMetrics.shutdown(boot 가 'mega-metrics' MegaShutdown hook 으로 등록)에서 일괄 해제되므로 별도 등록 불필요.
714
755
  MegaMetrics.subscribeJobs(worker)
756
+ // 잡 길목 로깅 (ADR-223) — MegaJobWorker 는 큐 이벤트를 재방출만 하고 logger 를 모른다(순수 런타임). 호스트가
757
+ // 구독해 로그로 남기지 않으면 production 에서 잡 영구 실패·DLQ 격리가 로그에 0 줄로 남아(메트릭/데모 페이지로만
758
+ // 보임) 운영자가 실패 신호를 못 받는다(mega-job-queue 설계: "fail/dlq 구독이 사실상 필수"). P5(에러 핸들러·
759
+ // async 경계 로그). 메트릭(subscribeJobs)과 독립적이라 둘 다 둔다. 페이로드 전체가 아닌 핵심 필드만 박는다(P5).
760
+ subscribeJobLogging(worker, log)
715
761
  // 등록 소스(ADR-123) = config.jobs(정적) + 플러그인 host.listJobs()(동적). register 가 subject/bus
716
762
  // 미선언·중복을 부팅 시 fail-fast.
717
763
  const jobs = collectRegistrations(global, host, 'jobs')
@@ -740,6 +786,9 @@ export async function runSchedulerHost(projectRoot, logger) {
740
786
  const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
741
787
  const log = await wireHostLogger(global, logger)
742
788
  const scheduler = new MegaScheduler({ ctx })
789
+ // 스케줄 길목 로깅 (ADR-223) — 워커와 같은 갭: MegaScheduler 도 이벤트(run/skip/done/fail)를 노출만 하고
790
+ // logger 를 모른다. 호스트가 구독하지 않으면 production 에서 스케줄 실패가 로그에 안 남는다. P5.
791
+ subscribeScheduleLogging(scheduler, log)
743
792
  // 등록 소스(ADR-123) = config.schedules(정적) + 플러그인 host.listSchedules()(동적). register 가
744
793
  // cron 미선언·중복을 부팅 시 fail-fast.
745
794
  const schedules = collectRegistrations(global, host, 'schedules')
@@ -0,0 +1,69 @@
1
+ // @ts-check
2
+ /**
3
+ * 부팅된 MegaApp 프로세스 레지스트리 — **ctx 없는 영역**(백그라운드 setInterval·외부 SDK 콜백·테스트 헬퍼)에서
4
+ * `getApp()` 으로 booted 앱에 접근해 `app.lock` / `app.bus` 등 process-level 표면을 쓴다 (ADR-228).
5
+ *
6
+ * # 왜 필요한가
7
+ * `ctx.lock`/`ctx.bus` 는 요청 ctx 에만 있어, 요청 흐름 밖(타이머·외부 라이브러리 콜백)에선 잡을 수 없었다.
8
+ * lock/bus manager 는 **process 싱글톤**(`setLockManager`/`setBusManager`)이라 요청과 무관하게 같은 인스턴스다.
9
+ * `getApp()` 은 그 싱글톤을 들고 있는 MegaApp 을 돌려줘, ctx 가 있을 때와 **동일 표면**(app.lock/app.bus)을 제공한다.
10
+ *
11
+ * # ctx 와의 차이
12
+ * `app.*` 는 **요청 무관** 자원만 — `lock`/`bus`(process manager) · `db(alias)`/`cache(alias)`(앱 별명 해석) ·
13
+ * `log`/`name`. 요청-스코프(`req`/`user`/`session`/`reply`/요청 locale `t`)는 **없다** — 그건 ctx 전용이다.
14
+ *
15
+ * @module core/app-registry
16
+ */
17
+ import { MegaError } from '../errors/mega-error.js'
18
+
19
+ /** @type {Map<string, import('./mega-app.js').MegaApp>} name → booted MegaApp(등록 순서). */
20
+ const apps = new Map()
21
+
22
+ /**
23
+ * booted MegaApp 을 레지스트리에 등록한다(boot 의 apps 스테이지가 앱마다 1회 호출).
24
+ * @param {import('./mega-app.js').MegaApp} app
25
+ * @returns {void}
26
+ */
27
+ export function setApp(app) {
28
+ apps.set(app.name, app)
29
+ }
30
+
31
+ /**
32
+ * booted MegaApp 을 가져온다 — ctx 없는 영역의 표준 접근점.
33
+ * - `name` 지정: 그 이름의 앱(없으면 `app.not_found`).
34
+ * - `name` 생략 + 앱 1개: 그 앱.
35
+ * - `name` 생략 + 앱 0개: `app.not_initialized`(부팅 전 호출 — fail-fast).
36
+ * - `name` 생략 + 앱 2개 이상: `app.ambiguous`(이름 필수).
37
+ *
38
+ * @param {string} [name] - 앱 이름(멀티앱 프로세스에서 지정).
39
+ * @returns {import('./mega-app.js').MegaApp}
40
+ * @throws {MegaError} `app.not_initialized` | `app.ambiguous` | `app.not_found`
41
+ */
42
+ export function getApp(name) {
43
+ if (name !== undefined) {
44
+ const app = apps.get(name)
45
+ if (!app) {
46
+ throw new MegaError('app.not_found', `getApp('${name}') — no booted app named '${name}'. Booted: [${[...apps.keys()].join(', ') || '(none)'}].`, {
47
+ details: { name, booted: [...apps.keys()] },
48
+ })
49
+ }
50
+ return app
51
+ }
52
+ if (apps.size === 0) {
53
+ throw new MegaError('app.not_initialized', 'getApp() called before boot — no app is initialized. Call after bootApp() completes (e.g. afterBoot hook or post-listen background tasks).', { details: {} })
54
+ }
55
+ if (apps.size > 1) {
56
+ throw new MegaError('app.ambiguous', `getApp() is ambiguous — ${apps.size} apps booted ([${[...apps.keys()].join(', ')}]). Pass a name: getApp('<name>').`, { details: { booted: [...apps.keys()] } })
57
+ }
58
+ return /** @type {import('./mega-app.js').MegaApp} */ ([...apps.values()][0])
59
+ }
60
+
61
+ /** 등록된 앱이 있나(부팅 여부 가드 — `getApp` throw 없이 확인). @returns {boolean} */
62
+ export function hasApp() {
63
+ return apps.size > 0
64
+ }
65
+
66
+ /** 테스트 격리/재부팅용 — 레지스트리 비움(shutdown 에서도 호출). @returns {void} */
67
+ export function _resetApps() {
68
+ apps.clear()
69
+ }
package/src/core/boot.js CHANGED
@@ -46,6 +46,9 @@ import { MegaShutdown } from '../lib/mega-shutdown.js'
46
46
  import { buildLogger } from '../lib/mega-logger.js'
47
47
  import { buildFromGlobalConfig, connectAll, get as getAdapter, entries as adapterEntries } from '../adapters/adapter-manager.js'
48
48
  import { buildWorkers, startAll as startWorkers, contextProxy as workersContext } from './workers-manager.js'
49
+ import { createLockManager, setLockManager, attachLockApi } from './lock/index.js'
50
+ import { createBusManager, setBusManager, attachBusApi } from './bus/index.js'
51
+ import { setApp, _resetApps } from './app-registry.js'
49
52
  import { MegaWsCluster } from './ws-cluster.js'
50
53
  import { MegaWsRedisRoster } from './ws-roster.js'
51
54
  import * as MegaMetrics from '../lib/mega-metrics.js'
@@ -95,6 +98,33 @@ function composeAspConfig(globalAsp, appAsp) {
95
98
  return { ...appAsp, ...(masterSecret ? { masterSecret } : {}) }
96
99
  }
97
100
 
101
+ /**
102
+ * lock redis driver 가 빌릴 raw ioredis 를 cache 어댑터에서 빌린다(eval/duplicate 보유 검증). 글로벌·앱별 lock
103
+ * 스테이지가 공유한다. 없거나 raw redis 가 아니면 null(auto 면 폴백, 명시 redis 면 factory 가 fail-fast).
104
+ * @param {string | undefined} cacheKey - lock.cache(글로벌 services.caches 키). @param {any} [logger]
105
+ * @returns {any | null}
106
+ */
107
+ function borrowLockRedis(cacheKey, logger) {
108
+ if (!cacheKey) return null
109
+ const native = /** @type {any} */ (getAdapter('cache', cacheKey))?.native
110
+ if (native && typeof native.eval === 'function' && typeof native.duplicate === 'function') return native
111
+ logger?.warn?.({ cache: cacheKey }, 'boot.lock: configured cache adapter has no raw ioredis (eval/duplicate) — redis lock driver unavailable')
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * bus nats driver 가 빌릴 raw NatsConnection 을 bus 어댑터에서 빌린다(publish/subscribe/request 보유 검증).
117
+ * @param {string | undefined} natsKey - bus.nats(글로벌 services.buses 키). @param {any} [logger]
118
+ * @returns {any | null}
119
+ */
120
+ function borrowBusNc(natsKey, logger) {
121
+ if (!natsKey) return null
122
+ const native = /** @type {any} */ (getAdapter('bus', natsKey))?.native
123
+ if (native && typeof native.publish === 'function' && typeof native.subscribe === 'function' && typeof native.request === 'function') return native
124
+ logger?.warn?.({ nats: natsKey }, 'boot.bus: configured bus adapter has no NATS native (publish/subscribe/request) — nats bus driver unavailable')
125
+ return null
126
+ }
127
+
98
128
  /**
99
129
  * boot/CLI 컨텍스트 — `db/cache/bus/lock` 을 글로벌 키로 직접 조회하고, `workers` 는 `static name` 으로
100
130
  * lookup 한다(앱 별명 변환 없음 — 둘 다 글로벌 자원). worker/scheduler CLI 도 같은 형태를 재사용한다
@@ -222,6 +252,50 @@ const PREPARE_STEPS = [
222
252
  st.logger?.debug?.('boot.adapters connected')
223
253
  },
224
254
  },
255
+ {
256
+ name: 'lock',
257
+ needs: ['global', 'host'],
258
+ run: (st) => {
259
+ // 분산 락 manager 자동배선 (ADR-226) — `ctx.lock.with/.acquire/...` 사용자 API. driver 자동 폴백:
260
+ // redis cache 어댑터(config `lock.cache`) → redis(진짜 분산) / cluster 워커 → cluster(단일 노드) /
261
+ // 둘 다 없음 → memory(분산 미보장, 경고). 명시 driver 의 전제 부재는 factory 가 fail-fast(P7).
262
+ // adapters 스테이지 이후라 cache 의 raw ioredis(`.native`)가 connect 된 상태다. setLockManager 싱글톤을
263
+ // ctx-builder 가 읽어 모든 ctx(HTTP·worker·scheduler)의 lock accessor 에 API 를 얹는다.
264
+ // 글로벌 lock manager — 앱별 lock 설정이 없는 앱의 fallback + worker/scheduler boot ctx(MegaApp 없음)용.
265
+ // 앱별 분리(ADR-229)는 apps 스테이지가 app.config.lock 이 있을 때만 별도 manager 를 만들어 덮어쓴다.
266
+ const lockCfg = /** @type {any} */ (st.global).lock ?? {}
267
+ const manager = createLockManager(lockCfg, { logger: st.logger, redisClient: borrowLockRedis(lockCfg.cache, st.logger), isClusterWorker: nodeCluster.isWorker })
268
+ setLockManager(manager)
269
+ // 'app' stage — 어댑터 disconnect 보다 먼저 정리(redis 구독 연결 quit·타이머 해제가 cache 끊기기 전에).
270
+ MegaShutdown.register('mega-lock', async () => {
271
+ await manager.close()
272
+ setLockManager(null)
273
+ })
274
+ st.logger?.debug?.({ driver: manager.driverName, configured: lockCfg.driver ?? 'auto' }, 'boot.lock manager ready (ADR-226)')
275
+ },
276
+ },
277
+ {
278
+ name: 'bus',
279
+ needs: ['global', 'host'],
280
+ run: (st) => {
281
+ // 메시지 버스 manager 자동배선 (ADR-227) — `ctx.bus.emit/.on/.request/...` 사용자 API. driver 자동 폴백:
282
+ // NATS bus 어댑터(config `bus.nats`) → nats(진짜 분산) / cluster 워커 → cluster(단일 노드) /
283
+ // 둘 다 없음 → memory(분산 미보장, 경고). 명시 driver 의 전제 부재는 factory 가 fail-fast(P7).
284
+ // adapters 스테이지 이후라 NATS 어댑터의 raw NatsConnection(`.native`)이 connect 된 상태다. 새 연결을 만들지
285
+ // 않고 빌려 쓰므로 클러스터 broadcast(ADR-176)·잡 큐(ADR-119)와 같은 nc 를 공유한다.
286
+ // 글로벌 bus manager — 앱별 bus 설정이 없는 앱의 fallback + worker/scheduler boot ctx 용. 앱별 분리(ADR-229)는
287
+ // apps 스테이지가 app.config.bus 가 있을 때만 별도 manager 로 덮어쓴다.
288
+ const busCfg = /** @type {any} */ (st.global).bus ?? {}
289
+ const manager = createBusManager(busCfg, { logger: st.logger, nc: borrowBusNc(busCfg.nats, st.logger), isClusterWorker: nodeCluster.isWorker })
290
+ setBusManager(manager)
291
+ // 'app' stage — 어댑터 disconnect 보다 먼저 정리(영속 consumer·구독이 nc 끊기기 전에).
292
+ MegaShutdown.register('mega-bus', async () => {
293
+ await manager.close()
294
+ setBusManager(null)
295
+ })
296
+ st.logger?.debug?.({ driver: manager.driverName, configured: busCfg.driver ?? 'auto' }, 'boot.bus manager ready (ADR-227)')
297
+ },
298
+ },
225
299
  {
226
300
  name: 'health-auto-checks',
227
301
  needs: ['global'],
@@ -492,8 +566,33 @@ const BOOT_STEPS = [
492
566
  st.logger?.debug?.({ app: name, filesLoaded }, 'boot.routes loaded')
493
567
  st.server.mount(app)
494
568
  megaApps.push(app)
569
+ // ctx 없는 영역의 getApp() 접근점에 등록(ADR-228).
570
+ setApp(app)
571
+
572
+ // 앱별 lock/bus 분리(ADR-229) — app.config.lock/bus 가 있으면 그 앱 전용 manager 를 만들어 이 앱의
573
+ // adapterAccessors 에 덮어쓴다(ctx.<app>·getApp(name) 둘 다 앱 manager 를 본다). 미지정 앱은 생성자가
574
+ // 이미 붙인 글로벌 manager 를 그대로 쓴다(중복 연결 없음). 앱 설정은 글로벌과 shallow merge(앱 키 우선).
575
+ const isWorker = nodeCluster.isWorker
576
+ const appLockCfg = /** @type {any} */ (config).lock
577
+ if (appLockCfg) {
578
+ const merged = { .../** @type {any} */ (st.global).lock, ...appLockCfg }
579
+ const lockMgr = createLockManager(merged, { logger: st.logger, redisClient: borrowLockRedis(merged.cache, st.logger), isClusterWorker: isWorker })
580
+ attachLockApi(/** @type {any} */ (app.adapterAccessors.lock), lockMgr)
581
+ MegaShutdown.register(`mega-lock:${name}`, async () => lockMgr.close())
582
+ st.logger?.debug?.({ app: name, driver: lockMgr.driverName }, 'boot.lock app-scoped manager (ADR-229)')
583
+ }
584
+ const appBusCfg = /** @type {any} */ (config).bus
585
+ if (appBusCfg) {
586
+ const merged = { .../** @type {any} */ (st.global).bus, ...appBusCfg }
587
+ const busMgr = createBusManager(merged, { logger: st.logger, nc: borrowBusNc(merged.nats, st.logger), isClusterWorker: isWorker })
588
+ attachBusApi(/** @type {any} */ (app.adapterAccessors.bus), busMgr)
589
+ MegaShutdown.register(`mega-bus:${name}`, async () => busMgr.close())
590
+ st.logger?.debug?.({ app: name, driver: busMgr.driverName }, 'boot.bus app-scoped manager (ADR-229)')
591
+ }
495
592
  }
496
593
  st.megaApps = megaApps
594
+ // 재부팅/테스트 격리 — 'app' stage 에서 레지스트리 비움(어댑터 disconnect 와 같은 시점, getApp 가 stale 앱 X).
595
+ MegaShutdown.register('mega-app-registry', () => _resetApps())
497
596
  },
498
597
  },
499
598
  {