mega-framework 0.1.10 → 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 (87) 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/lock-controller.js +117 -0
  7. package/sample/crud/apps/main/locales/server/en.json +31 -1
  8. package/sample/crud/apps/main/locales/server/ko.json +31 -1
  9. package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
  10. package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
  11. package/sample/crud/apps/main/routes/bus.js +43 -0
  12. package/sample/crud/apps/main/routes/lock.js +35 -0
  13. package/sample/crud/apps/main/services/jobs-demo-service.js +21 -14
  14. package/sample/crud/apps/main/views/bus/index.ejs +80 -0
  15. package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
  16. package/sample/crud/apps/main/views/lock/index.ejs +99 -0
  17. package/sample/crud/docs/guide/03-service-model-db.md +48 -0
  18. package/sample/crud/docs/guide/05-scheduler-job-worker.md +3 -1
  19. package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
  20. package/sample/crud/docs/guide/10-multi-app.md +74 -0
  21. package/sample/crud/mega.config.js +32 -0
  22. package/sample/crud/package.json +3 -2
  23. package/sample/multi/.env +16 -0
  24. package/sample/multi/.env.example +17 -0
  25. package/sample/multi/README.md +54 -0
  26. package/sample/multi/apps/admin/app.config.js +24 -0
  27. package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
  28. package/sample/multi/apps/admin/public/js/admin.js +31 -0
  29. package/sample/multi/apps/admin/routes/pages.js +11 -0
  30. package/sample/multi/apps/admin/views/index.ejs +33 -0
  31. package/sample/multi/apps/web/app.config.js +30 -0
  32. package/sample/multi/apps/web/controllers/web-controller.js +45 -0
  33. package/sample/multi/apps/web/public/js/web.js +24 -0
  34. package/sample/multi/apps/web/routes/pages.js +13 -0
  35. package/sample/multi/apps/web/views/index.ejs +51 -0
  36. package/sample/multi/mega.config.js +42 -0
  37. package/sample/multi/package.json +20 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/nats-adapter.js +39 -44
  40. package/src/adapters/nats-codec.js +38 -0
  41. package/src/cli/commands/scaffold.js +1 -0
  42. package/src/cli/index.js +9 -1
  43. package/src/core/app-registry.js +69 -0
  44. package/src/core/boot.js +99 -0
  45. package/src/core/bus/cluster-bus.js +190 -0
  46. package/src/core/bus/contract.js +123 -0
  47. package/src/core/bus/index.js +285 -0
  48. package/src/core/bus/memory-bus.js +103 -0
  49. package/src/core/bus/nats-bus.js +203 -0
  50. package/src/core/config-validator.js +118 -1
  51. package/src/core/ctx-builder.js +14 -1
  52. package/src/core/index.js +2 -0
  53. package/src/core/lock/cluster-lock.js +174 -0
  54. package/src/core/lock/contract.js +123 -0
  55. package/src/core/lock/fifo-waitlist.js +93 -0
  56. package/src/core/lock/index.js +292 -0
  57. package/src/core/lock/memory-lock.js +162 -0
  58. package/src/core/lock/redis-lock.js +276 -0
  59. package/src/core/mega-app.js +29 -0
  60. package/src/core/migration/generate.js +1 -1
  61. package/src/core/migration/journal.js +1 -1
  62. package/src/core/scope-registry.js +9 -0
  63. package/src/eslint-plugin/no-direct-model-import.js +2 -2
  64. package/src/index.js +2 -0
  65. package/src/lib/mega-job-queue.js +71 -47
  66. package/types/adapters/mega-adapter.d.ts +1 -1
  67. package/types/adapters/nats-adapter.d.ts +4 -4
  68. package/types/adapters/nats-codec.d.ts +13 -0
  69. package/types/adapters/redlock-adapter.d.ts +1 -1
  70. package/types/core/app-registry.d.ts +22 -0
  71. package/types/core/bus/cluster-bus.d.ts +45 -0
  72. package/types/core/bus/contract.d.ts +164 -0
  73. package/types/core/bus/index.d.ts +100 -0
  74. package/types/core/bus/memory-bus.d.ts +45 -0
  75. package/types/core/bus/nats-bus.d.ts +41 -0
  76. package/types/core/index.d.ts +1 -0
  77. package/types/core/lock/cluster-lock.d.ts +44 -0
  78. package/types/core/lock/contract.d.ts +181 -0
  79. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  80. package/types/core/lock/index.d.ts +96 -0
  81. package/types/core/lock/memory-lock.d.ts +58 -0
  82. package/types/core/lock/redis-lock.d.ts +43 -0
  83. package/types/core/mega-app.d.ts +10 -0
  84. package/types/core/scope-registry.d.ts +6 -0
  85. package/types/index.d.ts +1 -1
  86. package/types/lib/mega-job-queue.d.ts +27 -4
  87. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -38,13 +38,22 @@
38
38
  * `fail`(최종 실패 — phase 로 단계 구분) · `dlq`(DLQ 라우팅). 소비자가 구독해 로그를 박는다.
39
39
  * 타이머·콜백 경로라 호출자가 없으므로 `fail`/`dlq` 구독이 사실상 필수다.
40
40
  *
41
+ * # nats v3 (`@nats-io/jetstream`) — JetStream API 위치 변경 (ADR-225)
42
+ * v2 `nats` 단일 패키지에서 `@nats-io/*` 로 분리됐다. JetStream client/manager 는 `nc.jetstream()`/
43
+ * `nc.jetstreamManager()` **메서드**에서 `jetstream(nc)`/`jetstreamManager(nc)` **함수**로 바뀌었고,
44
+ * enum(RetentionPolicy/StorageType/AckPolicy)은 `@nats-io/jetstream`, `nanos`/codec 은
45
+ * `@nats-io/nats-core` 로 이동, stream/consumer "not found" 가 문자열 code('404')에서 전용 에러
46
+ * 클래스(`StreamNotFoundError`/`ConsumerNotFoundError`)로 바뀌었다. 잡 미사용 앱에 nats 로드를
47
+ * 강제하지 않으려 ensureReady 가 `@nats-io/*` 를 lazy import 한다(어댑터 정합).
48
+ *
41
49
  * @module lib/mega-job-queue
42
- * @see ADR-119, ADR-028 (3종 분리), ADR-112 (NATS 어댑터), ADR-029 (래핑)
50
+ * @see ADR-119, ADR-028 (3종 분리), ADR-112 (NATS 어댑터), ADR-225 (nats v3 전환), ADR-029 (래핑)
43
51
  */
44
52
  import { EventEmitter } from 'node:events'
45
53
  import { MegaConfigError } from '../errors/config-error.js'
46
54
  import { MegaJob, resolveJobRetryConfig, resolveJobRunTimeoutMs } from './mega-job.js'
47
55
  import { withRetry } from './mega-retry.js'
56
+ import { encodeJson, decodeJson } from '../adapters/nats-codec.js'
48
57
 
49
58
  /** 잡 큐가 노출하는 이벤트 화이트리스트(오타 차단 + 문서화 — 형제 클래스와 동일 정책). */
50
59
  const KNOWN_EVENTS = Object.freeze([
@@ -56,9 +65,6 @@ const KNOWN_EVENTS = Object.freeze([
56
65
  'dlq', // DLQ 라우팅 완료 — event.dlqSubject
57
66
  ])
58
67
 
59
- /** JetStream "stream/consumer not found" 에러 코드(실측 — nats 2.29, code '404'). */
60
- const NOT_FOUND_CODE = '404'
61
-
62
68
  /** DLQ 스트림 `max_age` 디폴트(ms) — 7일. 무한 적재 방지(ADR-134). `dlqMaxAgeMs: 0` 으로 끌 수 있다. */
63
69
  export const DEFAULT_DLQ_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
64
70
 
@@ -86,7 +92,7 @@ function truncateStack(stack) {
86
92
 
87
93
  /**
88
94
  * @typedef {Object} MegaJobQueueOptions
89
- * @property {import('nats').NatsConnection} nc - **연결된** NatsConnection(`ctx.bus(alias).native`).
95
+ * @property {import('@nats-io/nats-core').NatsConnection} nc - **연결된** NatsConnection(`ctx.bus(alias).native`).
90
96
  * @property {number} [ackWaitMs=30000] - consumer ack 대기(ms). 초과 시 JetStream 재전달. `working()`
91
97
  * 하트비트가 이 값의 절반마다 lease 를 갱신한다.
92
98
  * @property {number} [maxDeliver=5] - JetStream 최대 전달 횟수(워커 크래시 재전달 backstop).
@@ -112,6 +118,18 @@ function truncateStack(stack) {
112
118
  * @property {Error} [error] - 실패 사유(실패 시).
113
119
  */
114
120
 
121
+ /**
122
+ * ensureReady 가 lazy import 로 채우는 v3 nats 심볼 묶음(ADR-225). enum·nanos·not-found 에러 클래스만
123
+ * 추려 보관해 잡 미사용 앱에 nats 전체 로드를 강제하지 않는다.
124
+ * @typedef {Object} MegaJobQueueNatsBundle
125
+ * @property {typeof import('@nats-io/jetstream').RetentionPolicy} RetentionPolicy
126
+ * @property {typeof import('@nats-io/jetstream').AckPolicy} AckPolicy
127
+ * @property {typeof import('@nats-io/jetstream').StorageType} StorageType
128
+ * @property {typeof import('@nats-io/nats-core').nanos} nanos
129
+ * @property {typeof import('@nats-io/jetstream').JetStreamApiError} JetStreamApiError
130
+ * @property {typeof import('@nats-io/jetstream').JetStreamApiCodes} JetStreamApiCodes
131
+ */
132
+
115
133
  /**
116
134
  * JetStream 잡 큐 런타임. {@link MegaJob} 클래스를 받아 enqueue/consume·재시도·DLQ 를 처리한다.
117
135
  *
@@ -124,7 +142,7 @@ function truncateStack(stack) {
124
142
  * await sub.stop()
125
143
  */
126
144
  export class MegaJobQueue extends EventEmitter {
127
- /** @type {import('nats').NatsConnection} */ #nc
145
+ /** @type {import('@nats-io/nats-core').NatsConnection} */ #nc
128
146
  /** @type {number} */ #ackWaitMs
129
147
  /** @type {number} */ #maxDeliver
130
148
  /** @type {number} */ #heartbeatMs
@@ -132,10 +150,9 @@ export class MegaJobQueue extends EventEmitter {
132
150
  /** @type {number} DLQ max_age(ms). 0 = 무제한. */ #dlqMaxAgeMs
133
151
  /** @type {number|undefined} DLQ max_bytes. undefined = 무제한. */ #dlqMaxBytes
134
152
  /** @type {number} run 전체 실행 상한 디폴트(ms). 0 = 무제한. 잡별 static timeoutMs 가 우선. */ #runTimeoutMs
135
- /** @type {typeof import('nats')|null} 지연 로드된 nats 모듈(enum/codec/nanos). */ #nats = null
136
- /** @type {import('nats').Codec<any>|null} */ #codec = null
137
- /** @type {import('nats').JetStreamClient|null} */ #js = null
138
- /** @type {import('nats').JetStreamManager|null} */ #jsm = null
153
+ /** @type {MegaJobQueueNatsBundle|null} 지연 로드된 v3 nats 심볼 묶음. ensureReady 가 채운다. */ #nats = null
154
+ /** @type {import('@nats-io/jetstream').JetStreamClient|null} */ #js = null
155
+ /** @type {import('@nats-io/jetstream').JetStreamManager|null} */ #jsm = null
139
156
  /** @type {Promise<void>|null} ensureReady 멱등 가드. */ #readyPromise = null
140
157
  /**
141
158
  * subject 별 ensureStream 멱등 캐시(#readyPromise 와 동일 패턴). 없으면 enqueue 가 매 호출
@@ -151,9 +168,11 @@ export class MegaJobQueue extends EventEmitter {
151
168
  */
152
169
  constructor({ nc, ackWaitMs = 30_000, maxDeliver = 5, heartbeatMs, streamPrefix = 'MEGA_JOBS', dlqMaxAgeMs = DEFAULT_DLQ_MAX_AGE_MS, dlqMaxBytes, runTimeoutMs = DEFAULT_RUN_TIMEOUT_MS } = /** @type {any} */ ({})) {
153
170
  super()
154
- if (!nc || typeof nc.jetstream !== 'function' || typeof nc.jetstreamManager !== 'function') {
171
+ // v3(ADR-225): jetstream()/jetstreamManager() 이상 nc 메서드가 아니라 standalone 함수다.
172
+ // nc 는 core NatsConnection 이면 충분(publish/subscribe 계약) — jetstream(nc)/jetstreamManager(nc) 에 넘긴다.
173
+ if (!nc || typeof nc.publish !== 'function' || typeof nc.subscribe !== 'function') {
155
174
  throw new TypeError(
156
- 'MegaJobQueue({ nc }) — nc must be a connected NatsConnection (jetstream()/jetstreamManager()).',
175
+ 'MegaJobQueue({ nc }) — nc must be a connected NatsConnection (publish()/subscribe()).',
157
176
  )
158
177
  }
159
178
  if (typeof ackWaitMs !== 'number' || ackWaitMs <= 0) {
@@ -255,11 +274,21 @@ export class MegaJobQueue extends EventEmitter {
255
274
  async ensureReady() {
256
275
  if (this.#readyPromise) return this.#readyPromise
257
276
  this.#readyPromise = (async () => {
258
- const nats = await import('nats')
259
- this.#nats = nats
260
- this.#codec = nats.JSONCodec()
261
- this.#js = this.#nc.jetstream()
262
- this.#jsm = await this.#nc.jetstreamManager()
277
+ // v3(ADR-225): JetStream client/manager 는 standalone 함수(`jetstream(nc)`/`jetstreamManager(nc)`),
278
+ // enum 은 @nats-io/jetstream, nanos 는 @nats-io/nats-core. 둘을 병렬 lazy import(잡 미사용 앱 보호).
279
+ const [js, core] = await Promise.all([import('@nats-io/jetstream'), import('@nats-io/nats-core')])
280
+ // not-found 는 전용 서브클래스가 top-level export 가 아니라(jserrors 내부), base JetStreamApiError +
281
+ // JetStreamApiCodes(StreamNotFound=10059/ConsumerNotFound=10014)로 판별한다(실측 — v3 런타임 export 표면).
282
+ this.#nats = {
283
+ RetentionPolicy: js.RetentionPolicy,
284
+ AckPolicy: js.AckPolicy,
285
+ StorageType: js.StorageType,
286
+ nanos: core.nanos,
287
+ JetStreamApiError: js.JetStreamApiError,
288
+ JetStreamApiCodes: js.JetStreamApiCodes,
289
+ }
290
+ this.#js = js.jetstream(this.#nc)
291
+ this.#jsm = await js.jetstreamManager(this.#nc)
263
292
  })()
264
293
  return this.#readyPromise
265
294
  }
@@ -356,7 +385,7 @@ export class MegaJobQueue extends EventEmitter {
356
385
  if (cached) return cached
357
386
  const ensured = (async () => {
358
387
  await this.ensureReady()
359
- const nats = /** @type {typeof import('nats')} */ (this.#nats)
388
+ const nats = /** @type {MegaJobQueueNatsBundle} */ (this.#nats)
360
389
  await this.#ensureStreamExists(this.#workStreamName(subject), [subject], nats.RetentionPolicy.Workqueue)
361
390
  // DLQ 스트림에 한도를 건다(무한 적재 방지, ADR-134). dlqMaxAgeMs=0 이면 max_age 미지정(무제한),
362
391
  // dlqMaxBytes 미지정이면 max_bytes 미지정.
@@ -380,20 +409,22 @@ export class MegaJobQueue extends EventEmitter {
380
409
  * 단일 스트림 멱등 생성 — info 로 존재 확인 후 없으면 add. not-found 외 에러는 전파. `limits` 가
381
410
  * 주어지면 생성 시 `max_age`/`max_bytes` 를 함께 설정한다(DLQ 한도, ADR-134). 이미 존재하는 스트림은
382
411
  * 갱신하지 않는다(멱등 — 설정 변경은 운영 책임).
383
- * @param {string} name @param {string[]} subjects @param {import('nats').RetentionPolicy} retention
412
+ * @param {string} name @param {string[]} subjects @param {import('@nats-io/jetstream').RetentionPolicy} retention
384
413
  * @param {{ maxAgeMs?: number, maxBytes?: number }} [limits]
385
414
  * @returns {Promise<void>}
386
415
  */
387
416
  async #ensureStreamExists(name, subjects, retention, limits) {
388
- const jsm = /** @type {import('nats').JetStreamManager} */ (this.#jsm)
389
- const nats = /** @type {typeof import('nats')} */ (this.#nats)
417
+ const jsm = /** @type {import('@nats-io/jetstream').JetStreamManager} */ (this.#jsm)
418
+ const nats = /** @type {MegaJobQueueNatsBundle} */ (this.#nats)
390
419
  try {
391
420
  await jsm.streams.info(name)
392
421
  return // 이미 존재.
393
422
  } catch (e) {
394
- if (/** @type {any} */ (e)?.code !== NOT_FOUND_CODE) throw e // 진짜 장애는 묻지 않는다.
423
+ // v3(ADR-225): not-found 문자열 code('404')에서 JetStreamApiError(code=StreamNotFound) 바뀜. 전파.
424
+ if (!(e instanceof nats.JetStreamApiError && e.code === nats.JetStreamApiCodes.StreamNotFound)) throw e
395
425
  }
396
- /** @type {Record<string, any>} */
426
+ // v3 StreamConfig `name` 필수(WithRequired) — name 을 가진 partial StreamConfig 로 타입 고정.
427
+ /** @type {import('@nats-io/nats-core').WithRequired<Partial<import('@nats-io/jetstream').StreamConfig>, 'name'>} */
397
428
  const config = { name, subjects, retention, storage: nats.StorageType.File }
398
429
  if (limits) {
399
430
  // max_age 는 nanos(ms→ns). 0 은 NATS 에서 "무제한" 이므로 양수일 때만 설정(미지정=무제한과 동일).
@@ -408,13 +439,14 @@ export class MegaJobQueue extends EventEmitter {
408
439
  * @param {string} stream @param {string} durable @param {string} subject @param {number} concurrency @returns {Promise<void>}
409
440
  */
410
441
  async #ensureConsumer(stream, durable, subject, concurrency) {
411
- const jsm = /** @type {import('nats').JetStreamManager} */ (this.#jsm)
412
- const nats = /** @type {typeof import('nats')} */ (this.#nats)
442
+ const jsm = /** @type {import('@nats-io/jetstream').JetStreamManager} */ (this.#jsm)
443
+ const nats = /** @type {MegaJobQueueNatsBundle} */ (this.#nats)
413
444
  try {
414
445
  await jsm.consumers.info(stream, durable)
415
446
  return
416
447
  } catch (e) {
417
- if (/** @type {any} */ (e)?.code !== NOT_FOUND_CODE) throw e
448
+ // v3(ADR-225): consumer not-found = JetStreamApiError(code=ConsumerNotFound). 에러는 전파.
449
+ if (!(e instanceof nats.JetStreamApiError && e.code === nats.JetStreamApiCodes.ConsumerNotFound)) throw e
418
450
  }
419
451
  await jsm.consumers.add(stream, {
420
452
  durable_name: durable,
@@ -442,11 +474,11 @@ export class MegaJobQueue extends EventEmitter {
442
474
  }
443
475
  await this.ensureStream(JobClass)
444
476
  const subject = /** @type {string} */ (JobClass.subject)
445
- const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
477
+ const js = /** @type {import('@nats-io/jetstream').JetStreamClient} */ (this.#js)
446
478
  // msgID(옵트인) = JetStream `Nats-Msg-Id` dedup — 스트림 duplicate window(NATS 기본 2분) 안의
447
479
  // 같은 msgID 재발행은 적재되지 않고 ack.duplicate=true 로 돌아온다(producer 중복: 재시도 enqueue·
448
480
  // 이중 클릭 방어). 미지정 시 dedup 없음(기존 동작, 비용 0) — duplicate 는 그때 항상 false.
449
- const ack = await js.publish(subject, this.#encode(payload), msgID !== undefined ? { msgID } : undefined)
481
+ const ack = await js.publish(subject, encodeJson(payload), msgID !== undefined ? { msgID } : undefined)
450
482
  // dispatch 는 publish(부작용) 완료 후 발화 — 리스너 throw 가 반환값/흐름을 오염시키지 않게 safeEmit(L-3).
451
483
  this.#safeEmit('dispatch', { subject, seq: ack.seq, stream: ack.stream })
452
484
  return { seq: ack.seq, stream: ack.stream, duplicate: ack.duplicate }
@@ -478,7 +510,7 @@ export class MegaJobQueue extends EventEmitter {
478
510
  const runTimeoutMs = resolveJobRunTimeoutMs(JobClass, this.#runTimeoutMs) // 행 잡 영구 점유 backstop.
479
511
  await this.#ensureConsumer(stream, durable, subject, concurrency)
480
512
 
481
- const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
513
+ const js = /** @type {import('@nats-io/jetstream').JetStreamClient} */ (this.#js)
482
514
  const consumer = await js.consumers.get(stream, durable)
483
515
  const messages = await consumer.consume({ max_messages: concurrency })
484
516
 
@@ -506,8 +538,8 @@ export class MegaJobQueue extends EventEmitter {
506
538
 
507
539
  return {
508
540
  stop: async () => {
509
- // ConsumerMessages.close() 는 Promise<void|Error> 를 반환(nats 2.29 consumer.d.ts) — drain
510
- // 오류면 Error 인스턴스를 resolve 한다(reject 아님). 묵히지 않고 stderr 로 표면화한다(L-3).
541
+ // ConsumerMessages.close() 는 Promise<void|Error> 를 반환(@nats-io/jetstream v3 동일 계약) — drain
542
+ // 오류면 Error 인스턴스를 resolve 한다(reject 아님). 묵히지 않고 stderr 로 표면화한다(L-3).
511
543
  const closeResult = await messages.close()
512
544
  if (closeResult instanceof Error) {
513
545
  console.error(`[mega-job-queue] consumer close reported an error on '${subject}':`, closeResult)
@@ -522,7 +554,7 @@ export class MegaJobQueue extends EventEmitter {
522
554
  * in-flight Promise 가 reject 되지 않게 — MegaScheduler #fire 와 동일 불변식). 내부/테스트용 seam 이라
523
555
  * `_` 접두사(어댑터 `_connect` 컨벤션).
524
556
  *
525
- * @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('nats').JsMsg} msg
557
+ * @param {MegaJob} instance @param {Record<string, any>} ctx @param {import('@nats-io/jetstream').JsMsg} msg
526
558
  * @param {ReturnType<typeof resolveJobRetryConfig>} retryConfig @param {string} subject
527
559
  * @param {number} [runTimeoutMs] - run 전체(재시도 포함) 상한(ms). 미지정 시 큐 디폴트, 0 = 무제한.
528
560
  * @returns {Promise<MegaJobHandleResult>}
@@ -545,7 +577,7 @@ export class MegaJobQueue extends EventEmitter {
545
577
  this.#safeEmit('fail', { subject, seq, error, phase: 'max-deliver' })
546
578
  let exhaustedPayload
547
579
  try {
548
- exhaustedPayload = this.#decode(msg.data)
580
+ exhaustedPayload = decodeJson(msg.data)
549
581
  } catch {
550
582
  // 디코드까지 실패한 poison 이면 raw 바이트를 봉투에 보관(무시 아님, DLQ 로 보존).
551
583
  exhaustedPayload = { decodeError: true, rawBase64: this.#toBase64(msg.data) }
@@ -556,7 +588,7 @@ export class MegaJobQueue extends EventEmitter {
556
588
 
557
589
  let payload
558
590
  try {
559
- payload = this.#decode(msg.data)
591
+ payload = decodeJson(msg.data)
560
592
  } catch (decodeErr) {
561
593
  // 디코드 불가 = poison 메시지. 재시도 무의미 → 곧장 DLQ(원본 바이트 base64 보관).
562
594
  const error = decodeErr instanceof Error ? decodeErr : new Error(String(decodeErr))
@@ -657,11 +689,11 @@ export class MegaJobQueue extends EventEmitter {
657
689
  * (at-least-once — 즉시 재전달 핫 루프 방지). 단 이번 전달이 `max_deliver` 의 **마지막 전달**이면 nak 해도
658
690
  * 재전달이 없어 메시지가 워크 스트림에 orphan 으로 남는다 — 이때는 un-ack 보존 + `fail(dlq-orphan)` 으로
659
691
  * 운영자 개입을 명시 표면화한다(ack/term 으로 잡을 지우면 유실이라 하지 않는다). 본 메서드도 throw 하지 않는다.
660
- * @param {string} subject @param {any} payload @param {Error} error @param {import('nats').JsMsg} msg @param {number} seq @returns {Promise<void>}
692
+ * @param {string} subject @param {any} payload @param {Error} error @param {import('@nats-io/jetstream').JsMsg} msg @param {number} seq @returns {Promise<void>}
661
693
  */
662
694
  async #routeToDlq(subject, payload, error, msg, seq) {
663
695
  const dlqSubject = `${subject}.dlq`
664
- const js = /** @type {import('nats').JetStreamClient} */ (this.#js)
696
+ const js = /** @type {import('@nats-io/jetstream').JetStreamClient} */ (this.#js)
665
697
  try {
666
698
  // DLQ publish 자체를 짧게 재시도(추가 2회, 250ms→1s) — 일시적 NATS 응답 지연/재연결 틈을 흡수해
667
699
  // nak 재전달(전체 인프로세스 재시도 사이클 반복)보다 훨씬 싸게 orphan 확률을 줄인다.
@@ -669,7 +701,7 @@ export class MegaJobQueue extends EventEmitter {
669
701
  () =>
670
702
  js.publish(
671
703
  dlqSubject,
672
- this.#encode({
704
+ encodeJson({
673
705
  originalSubject: subject,
674
706
  failedAt: new Date().toISOString(),
675
707
  deliveryCount: msg.info.deliveryCount,
@@ -700,16 +732,8 @@ export class MegaJobQueue extends EventEmitter {
700
732
  }
701
733
 
702
734
  // ── 헬퍼 ────────────────────────────────────────────────────────────────
703
-
704
- /** @param {any} value @returns {Uint8Array} JSONCodec 인코드(undefined→null). */
705
- #encode(value) {
706
- return /** @type {import('nats').Codec<any>} */ (this.#codec).encode(value === undefined ? null : value)
707
- }
708
-
709
- /** @param {Uint8Array} data @returns {any} JSONCodec 디코드. */
710
- #decode(data) {
711
- return /** @type {import('nats').Codec<any>} */ (this.#codec).decode(data)
712
- }
735
+ // JSON 인코드/디코드는 공유 코덱(adapters/nats-codec.js)의 encodeJson/decodeJson 을 직접 쓴다
736
+ // (v3 에서 JSONCodec 팩토리 제거 ADR-225). undefined→null 정규화·빈 payload→null 은 코덱이 담당.
713
737
 
714
738
  /** @param {Uint8Array} data @returns {string} base64(원본 바이트 보존용). */
715
739
  #toBase64(data) {
@@ -21,7 +21,7 @@ export class MegaAdapter {
21
21
  * 토큰이 없으면(옵트인 OFF) 아예 쓰이지 않아 0 비용.
22
22
  * @type {AsyncLocalStorage<any>}
23
23
  */
24
- static "__#private@#callScope": AsyncLocalStorage<any>;
24
+ static #callScope: AsyncLocalStorage<any>;
25
25
  /**
26
26
  * @param {object} [config] - driver 별 설정 (url / pool / file 등).
27
27
  */
@@ -19,9 +19,9 @@ export class MegaNatsAdapter extends MegaBusAdapter {
19
19
  /**
20
20
  * raw NatsConnection handle (ADR-009). `connect()` 후에만 베이스 `native` getter 가 호출한다.
21
21
  * @protected
22
- * @returns {import('nats').NatsConnection}
22
+ * @returns {import('@nats-io/nats-core').NatsConnection}
23
23
  */
24
- protected _native(): import("nats").NatsConnection;
24
+ protected _native(): import("@nats-io/nats-core").NatsConnection;
25
25
  /**
26
26
  * 헬스 체크 — 실제 `rtt`(서버 왕복 ping)로 응답성 확인. 실패는 throw 없이 `ok:false` + 사유.
27
27
  * @returns {Promise<{ ok: boolean, driver: 'nats', state: string, server?: string, rttMs?: number, error?: string }>}
@@ -36,12 +36,12 @@ export class MegaNatsAdapter extends MegaBusAdapter {
36
36
  }>;
37
37
  /**
38
38
  * 누적 통계 + nats 특화(server + 연결 stats). 연결 전이면 server/stats 는 undefined.
39
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, server: string | undefined, nats: import('nats').Stats | undefined }}
39
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, server: string | undefined, nats: import('@nats-io/nats-core').Stats | undefined }}
40
40
  */
41
41
  getStats(): ReturnType<import("./mega-adapter.js").MegaAdapter["getStats"]> & {
42
42
  driver: string;
43
43
  server: string | undefined;
44
- nats: import("nats").Stats | undefined;
44
+ nats: import("@nats-io/nats-core").Stats | undefined;
45
45
  };
46
46
  /**
47
47
  * 잡 enqueue — 단순 publish (queue group 분배는 `process` 측 책임, ADR-112).
@@ -0,0 +1,13 @@
1
+ /**
2
+ * JS 값 → NATS wire 바이트(JSON). `undefined` 는 `null` 로 정규화한다.
3
+ * @param {any} value
4
+ * @returns {Uint8Array}
5
+ */
6
+ export function encodeJson(value: any): Uint8Array;
7
+ /**
8
+ * NATS wire 바이트(JSON) → JS 값. 빈 payload(길이 0)는 `null`. 파싱 실패는 throw(호출부가 처리).
9
+ * @param {Uint8Array} data
10
+ * @returns {any}
11
+ * @throws {SyntaxError} JSON 파싱 실패 시(silent 금지 — poison 메시지 감지에 사용).
12
+ */
13
+ export function decodeJson(data: Uint8Array): any;
@@ -10,7 +10,7 @@ export class MegaRedlockAdapter extends MegaLockAdapter {
10
10
  * 보고 거부, 숫자 4종은 음 아닌 정수, driftFactor 는 [0,1) 유한수.
11
11
  * @param {unknown} options @returns {Partial<RedlockSettings>}
12
12
  */
13
- static "__#private@#normalizeSettings"(options: unknown): Partial<RedlockSettings>;
13
+ static #normalizeSettings(options: unknown): Partial<RedlockSettings>;
14
14
  /**
15
15
  * @param {RedlockConfig} [config] - services.locks.<key> 설정.
16
16
  * @throws {MegaValidationError} `lock.redis_required` - `redis` 키 누락/비문자열.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * booted MegaApp 을 레지스트리에 등록한다(boot 의 apps 스테이지가 앱마다 1회 호출).
3
+ * @param {import('./mega-app.js').MegaApp} app
4
+ * @returns {void}
5
+ */
6
+ export function setApp(app: import("./mega-app.js").MegaApp): void;
7
+ /**
8
+ * booted MegaApp 을 가져온다 — ctx 없는 영역의 표준 접근점.
9
+ * - `name` 지정: 그 이름의 앱(없으면 `app.not_found`).
10
+ * - `name` 생략 + 앱 1개: 그 앱.
11
+ * - `name` 생략 + 앱 0개: `app.not_initialized`(부팅 전 호출 — fail-fast).
12
+ * - `name` 생략 + 앱 2개 이상: `app.ambiguous`(이름 필수).
13
+ *
14
+ * @param {string} [name] - 앱 이름(멀티앱 프로세스에서 지정).
15
+ * @returns {import('./mega-app.js').MegaApp}
16
+ * @throws {MegaError} `app.not_initialized` | `app.ambiguous` | `app.not_found`
17
+ */
18
+ export function getApp(name?: string): import("./mega-app.js").MegaApp;
19
+ /** 등록된 앱이 있나(부팅 여부 가드 — `getApp` throw 없이 확인). @returns {boolean} */
20
+ export function hasApp(): boolean;
21
+ /** 테스트 격리/재부팅용 — 레지스트리 비움(shutdown 에서도 호출). @returns {void} */
22
+ export function _resetApps(): void;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * master 프로세스에 버스 라우터를 설치한다(멱등). 워커들의 구독을 모아 발행 시 fan-out 한다.
3
+ * `mega start` 클러스터 분기에서 master 가 1회 호출한다.
4
+ * @param {import('node:cluster').Cluster} [cluster] - 테스트 주입용(기본 node:cluster).
5
+ * @returns {{ subscriptions: () => number } | null} 관측용 핸들(테스트). 이미 설치됐으면 null.
6
+ */
7
+ export function installClusterBusMaster(cluster?: import("node:cluster").Cluster): {
8
+ subscriptions: () => number;
9
+ } | null;
10
+ /** 테스트 격리용 — master 설치 플래그 초기화. @returns {void} */
11
+ export function _resetClusterBusMaster(): void;
12
+ /**
13
+ * 워커 측 cluster 버스 driver — master 에 IPC 위임 + master 의 deliver 를 로컬 핸들러로 디스패치.
14
+ */
15
+ export class ClusterBusDriver {
16
+ /**
17
+ * @param {{ proc?: NodeJS.Process }} [opts] - `proc` 테스트 주입용(기본 process). cluster 워커여야 send 존재.
18
+ * @throws {Error} proc.send 가 없으면(cluster 워커 아님).
19
+ */
20
+ constructor({ proc }?: {
21
+ proc?: NodeJS.Process;
22
+ });
23
+ /** @type {'cluster'} */
24
+ get name(): "cluster";
25
+ /** cluster 는 persist 무시(opts 는 계약 정합용). @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ persist?: boolean }} [_opts] @returns {Promise<void>} */
26
+ publish(subject: string, envelope: import("./contract.js").BusEnvelope, _opts?: {
27
+ persist?: boolean;
28
+ }): Promise<void>;
29
+ /** @param {string} pattern @param {Function} handler @param {{ ordered?: boolean }} opts @returns {Promise<import('./contract.js').Subscription>} */
30
+ subscribe(pattern: string, handler: Function, { ordered }?: {
31
+ ordered?: boolean;
32
+ }): Promise<import("./contract.js").Subscription>;
33
+ /** @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ timeout: number }} opts @returns {Promise<import('./contract.js').BusEnvelope>} */
34
+ request(subject: string, envelope: import("./contract.js").BusEnvelope, { timeout }: {
35
+ timeout: number;
36
+ }): Promise<import("./contract.js").BusEnvelope>;
37
+ /** @returns {Promise<{ driver: string, subscriptions: number }>} */
38
+ stats(): Promise<{
39
+ driver: string;
40
+ subscriptions: number;
41
+ }>;
42
+ /** 정리 — IPC 리스너 제거 + 대기 요청 reject + 핸들러 비움. @returns {Promise<void>} */
43
+ close(): Promise<void>;
44
+ #private;
45
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @typedef {Object} BusEnvelope - wire/IPC 로 오가는 메시지 봉투(payload 와 meta 분리 — meta 는 traceId 등).
3
+ * @property {any} payload - 사용자 데이터.
4
+ * @property {Record<string, any>} [meta] - 부가 메타(전파용). 없으면 빈 객체로 취급.
5
+ */
6
+ /**
7
+ * @typedef {Object} EmitOpts - emit 옵션.
8
+ * @property {Record<string, any>} [meta] - 이 메시지의 meta.
9
+ * @property {boolean} [persist] - true 면 영속 전달(JetStream — nats-bus 만; 그 외 driver 는 경고 후 비영속).
10
+ */
11
+ /**
12
+ * @typedef {Object} OnOpts - on(구독) 옵션.
13
+ * @property {boolean} [persist] - true 면 영속 구독(JetStream consumer — nats-bus 만).
14
+ * @property {boolean} [ordered] - true 면 같은 subject 메시지를 순서대로 1건씩 직렬 처리(핸들러 await 직렬화).
15
+ */
16
+ /**
17
+ * @typedef {(payload: any, meta: Record<string, any>, subject: string) => any} BusHandler - 사용자 핸들러.
18
+ * 3번째 인자 `subject` 는 메시지가 도착한 **구체** subject(prefix 제거됨) — wildcard 구독에서 어느 subject 가
19
+ * 매칭됐는지 안다. `meta.subject` 에도 같은 값이 담긴다(정본 위치). request 대상이면 **반환값이 reply**.
20
+ */
21
+ /**
22
+ * @typedef {Object} Subscription - on/subscribe 가 돌려주는 구독 핸들.
23
+ * @property {() => Promise<void>} unsubscribe - 구독 해제.
24
+ */
25
+ /**
26
+ * @typedef {(envelope: BusEnvelope) => void} ReplyFn - driver 가 핸들러에 주는 응답 함수(request 일 때만).
27
+ *
28
+ * @typedef {Object} BusDriver - driver 가 구현하는 저수준 인터페이스(manager 가 호출).
29
+ * @property {string} name - 'nats' | 'cluster' | 'memory'.
30
+ * @property {(subject: string, envelope: BusEnvelope, opts: { persist?: boolean }) => Promise<void>} publish -
31
+ * fire-and-forget 발행(구독자 전원 fan-out).
32
+ * @property {(subject: string, handler: (envelope: BusEnvelope, reply?: ReplyFn, subject?: string) => any, opts: { persist?: boolean, ordered?: boolean }) => Promise<Subscription>} subscribe -
33
+ * 구독(subject 는 wildcard 가능). `reply` 는 request 로 들어온 메시지일 때만 제공. 핸들러의 3번째 인자는
34
+ * 매칭된 **구체** subject(driver 가 채워 manager 로 넘김 — wildcard 어디로 왔는지 안다).
35
+ * @property {(subject: string, envelope: BusEnvelope, opts: { timeout: number }) => Promise<BusEnvelope>} request -
36
+ * req/reply — 첫 응답 envelope 반환. 응답자 없으면 `bus.no_responders`, 시한 초과면 `bus.request_timeout` throw.
37
+ * @property {() => Promise<{ driver: string, subscriptions: number }>} stats - 현재 구독 수.
38
+ * @property {() => Promise<void>} close - 구독·타이머·IPC·연결참조 정리.
39
+ */
40
+ /**
41
+ * subject 정규화 — prefix 를 앞에 붙인다(네임스페이스 격리). prefix 가 빈 값이면 그대로.
42
+ * @param {string} subject @param {string} [prefix] - 예: 'app.'.
43
+ * @returns {string}
44
+ */
45
+ export function normalizeSubject(subject: string, prefix?: string): string;
46
+ /**
47
+ * subject 검증 — 비어있지 않고 공백/제어문자 없는 문자열. publish subject 는 wildcard 금지(구체적이어야 함).
48
+ * @param {string} subject @param {{ allowWildcard?: boolean }} [opts] - true 면 `*`/`>` 허용(구독용).
49
+ * @returns {void}
50
+ * @throws {MegaValidationError} `bus.invalid_subject`
51
+ */
52
+ export function assertSubject(subject: string, { allowWildcard }?: {
53
+ allowWildcard?: boolean;
54
+ }): void;
55
+ /**
56
+ * NATS 식 wildcard 매칭 — cluster/memory-bus 의 자체 패턴 매칭(nats-bus 는 NATS native 사용).
57
+ * `*` = 한 토큰, `>` = 한 토큰 이상(꼬리). 토큰 수가 정확히 맞아야 매칭(`>` 제외).
58
+ * @param {string} pattern - 구독 패턴(wildcard 가능). @param {string} subject - 구체 subject.
59
+ * @returns {boolean}
60
+ */
61
+ export function matchSubject(pattern: string, subject: string): boolean;
62
+ /** request/reply 응답 대기 디폴트(ms). config `bus.requestTimeoutMs` 로 조정. */
63
+ export const DEFAULT_REQUEST_TIMEOUT_MS: 5000;
64
+ /**
65
+ * - wire/IPC 로 오가는 메시지 봉투(payload 와 meta 분리 — meta 는 traceId 등).
66
+ */
67
+ export type BusEnvelope = {
68
+ /**
69
+ * - 사용자 데이터.
70
+ */
71
+ payload: any;
72
+ /**
73
+ * - 부가 메타(전파용). 없으면 빈 객체로 취급.
74
+ */
75
+ meta?: Record<string, any>;
76
+ };
77
+ /**
78
+ * - emit 옵션.
79
+ */
80
+ export type EmitOpts = {
81
+ /**
82
+ * - 이 메시지의 meta.
83
+ */
84
+ meta?: Record<string, any>;
85
+ /**
86
+ * - true 면 영속 전달(JetStream — nats-bus 만; 그 외 driver 는 경고 후 비영속).
87
+ */
88
+ persist?: boolean;
89
+ };
90
+ /**
91
+ * - on(구독) 옵션.
92
+ */
93
+ export type OnOpts = {
94
+ /**
95
+ * - true 면 영속 구독(JetStream consumer — nats-bus 만).
96
+ */
97
+ persist?: boolean;
98
+ /**
99
+ * - true 면 같은 subject 메시지를 순서대로 1건씩 직렬 처리(핸들러 await 직렬화).
100
+ */
101
+ ordered?: boolean;
102
+ };
103
+ /**
104
+ * - 사용자 핸들러.
105
+ * 3번째 인자 `subject` 는 메시지가 도착한 **구체** subject(prefix 제거됨) — wildcard 구독에서 어느 subject 가
106
+ * 매칭됐는지 안다. `meta.subject` 에도 같은 값이 담긴다(정본 위치). request 대상이면 **반환값이 reply**.
107
+ */
108
+ export type BusHandler = (payload: any, meta: Record<string, any>, subject: string) => any;
109
+ /**
110
+ * - on/subscribe 가 돌려주는 구독 핸들.
111
+ */
112
+ export type Subscription = {
113
+ /**
114
+ * - 구독 해제.
115
+ */
116
+ unsubscribe: () => Promise<void>;
117
+ };
118
+ /**
119
+ * - driver 가 핸들러에 주는 응답 함수(request 일 때만).
120
+ */
121
+ export type ReplyFn = (envelope: BusEnvelope) => void;
122
+ /**
123
+ * - driver 가 구현하는 저수준 인터페이스(manager 가 호출).
124
+ */
125
+ export type BusDriver = {
126
+ /**
127
+ * - 'nats' | 'cluster' | 'memory'.
128
+ */
129
+ name: string;
130
+ /**
131
+ * -
132
+ * fire-and-forget 발행(구독자 전원 fan-out).
133
+ */
134
+ publish: (subject: string, envelope: BusEnvelope, opts: {
135
+ persist?: boolean;
136
+ }) => Promise<void>;
137
+ /**
138
+ * -
139
+ * 구독(subject 는 wildcard 가능). `reply` 는 request 로 들어온 메시지일 때만 제공. 핸들러의 3번째 인자는
140
+ * 매칭된 **구체** subject(driver 가 채워 manager 로 넘김 — wildcard 어디로 왔는지 안다).
141
+ */
142
+ subscribe: (subject: string, handler: (envelope: BusEnvelope, reply?: ReplyFn, subject?: string) => any, opts: {
143
+ persist?: boolean;
144
+ ordered?: boolean;
145
+ }) => Promise<Subscription>;
146
+ /**
147
+ * -
148
+ * req/reply — 첫 응답 envelope 반환. 응답자 없으면 `bus.no_responders`, 시한 초과면 `bus.request_timeout` throw.
149
+ */
150
+ request: (subject: string, envelope: BusEnvelope, opts: {
151
+ timeout: number;
152
+ }) => Promise<BusEnvelope>;
153
+ /**
154
+ * - 현재 구독 수.
155
+ */
156
+ stats: () => Promise<{
157
+ driver: string;
158
+ subscriptions: number;
159
+ }>;
160
+ /**
161
+ * - 구독·타이머·IPC·연결참조 정리.
162
+ */
163
+ close: () => Promise<void>;
164
+ };