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
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+ /**
3
+ * MemoryBusDriver — 단일 프로세스 in-memory 메시지 버스 driver (ADR-227).
4
+ *
5
+ * Node 이벤트루프 위의 구독 목록 fan-out. **단일 프로세스 안에서만** 전달하므로 멀티 프로세스·멀티 노드에는
6
+ * 닿지 않는다 — factory 가 memory 를 고를 때 부팅 경고를 낸다(`src/core/bus/index.js`). 개발/테스트, 또는
7
+ * 분산이 불필요한 단일 인스턴스용. wildcard 는 {@link matchSubject} 로 자체 매칭한다.
8
+ *
9
+ * driver 계약은 {@link import('./contract.js').BusDriver} 를 따른다(typedef — 런타임 implements 아님).
10
+ * 핸들러 호출은 manager 가 넘긴 wrapper 라 **절대 reject 하지 않는다**(사용자 에러는 manager 가 잡아 로깅) —
11
+ * 그래서 driver 는 `void handler(...)` 로 안전하게 던지고 fan-out 을 막지 않는다.
12
+ *
13
+ * @module core/bus/memory-bus
14
+ */
15
+ import { matchSubject } from './contract.js'
16
+ import { MegaInternalError } from '../../errors/http-errors.js'
17
+
18
+ /**
19
+ * @typedef {Object} MemorySub - 구독 레코드.
20
+ * @property {string} pattern @property {(envelope: import('./contract.js').BusEnvelope, reply?: import('./contract.js').ReplyFn, subject?: string) => any} handler
21
+ * @property {boolean} ordered @property {Promise<void>} tail - ordered 직렬화용 꼬리 프라미스.
22
+ */
23
+
24
+ export class MemoryBusDriver {
25
+ /** @type {Map<number, MemorySub>} 구독 id → 레코드. */ #subs = new Map()
26
+ /** @type {number} 구독 id 시퀀스. */ #seq = 0
27
+
28
+ /** @type {'memory'} */
29
+ get name() {
30
+ return 'memory'
31
+ }
32
+
33
+ /** 매칭 구독에 fan-out. memory 는 persist 무시(opts 는 계약 정합용). @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ persist?: boolean }} [_opts] @returns {Promise<void>} */
34
+ async publish(subject, envelope, _opts = {}) {
35
+ for (const sub of this.#subs.values()) {
36
+ if (matchSubject(sub.pattern, subject)) this.#deliver(sub, envelope, subject)
37
+ }
38
+ }
39
+
40
+ /** 구독 등록. @param {string} pattern @param {MemorySub['handler']} handler @param {{ ordered?: boolean }} opts @returns {Promise<import('./contract.js').Subscription>} */
41
+ async subscribe(pattern, handler, { ordered = false } = {}) {
42
+ const id = ++this.#seq
43
+ this.#subs.set(id, { pattern, handler, ordered, tail: Promise.resolve() })
44
+ return {
45
+ unsubscribe: async () => {
46
+ this.#subs.delete(id)
47
+ },
48
+ }
49
+ }
50
+
51
+ /**
52
+ * req/reply — 매칭 핸들러에 replyFn 을 주고 첫 응답을 반환. 응답자 없으면 no_responders, 시한 초과면 timeout.
53
+ * @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ timeout: number }} opts
54
+ * @returns {Promise<import('./contract.js').BusEnvelope>}
55
+ */
56
+ async request(subject, envelope, { timeout }) {
57
+ const matches = [...this.#subs.values()].filter((s) => matchSubject(s.pattern, subject))
58
+ if (matches.length === 0) {
59
+ throw new MegaInternalError('bus.no_responders', `bus request("${subject}"): no subscriber matches the subject.`, { details: { subject } })
60
+ }
61
+ return new Promise((resolve, reject) => {
62
+ let settled = false
63
+ const timer = setTimeout(() => {
64
+ if (settled) return
65
+ settled = true
66
+ reject(new MegaInternalError('bus.request_timeout', `bus request("${subject}") timed out after ${timeout}ms.`, { details: { subject, timeout } }))
67
+ }, timeout)
68
+ timer.unref?.()
69
+ /** @type {import('./contract.js').ReplyFn} */
70
+ const reply = (replyEnv) => {
71
+ if (settled) return // 첫 응답만 채택(NATS request 의미).
72
+ settled = true
73
+ clearTimeout(timer)
74
+ resolve(replyEnv)
75
+ }
76
+ for (const sub of matches) this.#deliver(sub, envelope, subject, reply)
77
+ })
78
+ }
79
+
80
+ /** @returns {Promise<{ driver: string, subscriptions: number }>} */
81
+ async stats() {
82
+ return { driver: 'memory', subscriptions: this.#subs.size }
83
+ }
84
+
85
+ /** 정리 — 구독 비움. @returns {Promise<void>} */
86
+ async close() {
87
+ this.#subs.clear()
88
+ }
89
+
90
+ /**
91
+ * 한 구독에 전달 — ordered 면 꼬리 프라미스에 직렬 연결, 아니면 즉시 호출. handler 는 reject 안 하므로 안전.
92
+ * `subject` 는 매칭된 **구체** subject(wildcard 가 아닌 실제 발행 subject) — 핸들러가 어디로 왔는지 안다.
93
+ * @param {MemorySub} sub @param {import('./contract.js').BusEnvelope} envelope @param {string} subject @param {import('./contract.js').ReplyFn} [reply]
94
+ * @returns {void}
95
+ */
96
+ #deliver(sub, envelope, subject, reply) {
97
+ if (sub.ordered) {
98
+ sub.tail = sub.tail.then(() => sub.handler(envelope, reply, subject))
99
+ } else {
100
+ void sub.handler(envelope, reply, subject)
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,203 @@
1
+ // @ts-check
2
+ /**
3
+ * NatsBusDriver — NATS 기반 **분산** 메시지 버스 driver (ADR-227).
4
+ *
5
+ * 멀티 프로세스·멀티 노드에 닿는 디폴트 driver. NATS core pub/sub(실시간 fan-out·비영속) 위에서 동작하고,
6
+ * `persist:true` 옵션이면 JetStream(영속 저장 + 재시작 후 재전달)으로 전환한다. NATS 가 wildcard·request/reply 를
7
+ * native 로 지원하므로 그대로 위임한다(매처 자체 구현 불필요). nats v3(`@nats-io/*`, ADR-225) 위에서 돈다.
8
+ *
9
+ * NATS 연결은 **bus 어댑터가 소유한 것을 빌린다**(`config.bus.nats` = 버스 어댑터 alias). 별도 연결을 만들지
10
+ * 않으므로 클러스터 broadcast(ADR-176)·잡 큐(ADR-119)와 **같은 NatsConnection** 을 공유한다 — close 에서 nc 를
11
+ * 닫지 않는다(생애주기는 어댑터 소유).
12
+ *
13
+ * # 영속(persist) — 비영속과 subject 공간 분리
14
+ * JetStream 스트림은 설정된 subject 로 **들어오는 모든 메시지**를 저장하므로, core(비영속) 메시지까지 스트림에
15
+ * 잡히면 이중 저장된다. 이를 막으려 영속 메시지는 내부 prefix `_mbusp.` 를 붙인 별도 subject 로 보낸다 —
16
+ * 영속 emit↔on 은 이 공간에서 만나고, core 와 섞이지 않는다. 스트림 1개(`MEGABUS_PERSIST`)가 `_mbusp.>` 를
17
+ * 잡고, 영속 구독은 그 위에 ephemeral consumer(filter_subject=패턴)를 만든다.
18
+ *
19
+ * driver 계약은 {@link import('./contract.js').BusDriver} 를 따른다(typedef — 런타임 implements 아님).
20
+ * @module core/bus/nats-bus
21
+ */
22
+ import { encodeJson, decodeJson } from '../../adapters/nats-codec.js'
23
+ import { MegaInternalError } from '../../errors/http-errors.js'
24
+
25
+ /** 영속 메시지 subject 내부 prefix(core 와 분리). */
26
+ const PERSIST_PREFIX = '_mbusp.'
27
+ /** 영속 스트림 이름(공유 — subject prefix 로 앱 격리). */
28
+ const PERSIST_STREAM = 'MEGABUS_PERSIST'
29
+
30
+ export class NatsBusDriver {
31
+ /** @type {any} 빌린 NatsConnection(bus 어댑터 소유). */ #nc
32
+ /** @type {any} @nats-io/jetstream 모듈(lazy). */ #js = null
33
+ /** @type {(ms: number) => number} nanos(ms→ns) — @nats-io/nats-core(lazy). */ #nanos = (ms) => ms
34
+ /** @type {any} JetStreamClient(lazy). */ #jsc = null
35
+ /** @type {any} JetStreamManager(lazy). */ #jsm = null
36
+ /** @type {Promise<void> | null} JetStream 초기화 직렬화. */ #jsInit = null
37
+ /** @type {boolean} 영속 스트림 보장 완료. */ #streamEnsured = false
38
+ /** @type {Set<{ stop: () => void }>} 활성 영속 consumer(close 에서 정리). */ #persistSubs = new Set()
39
+
40
+ /** @param {{ nc: any }} opts - `nc` 빌린 NatsConnection(`busAdapter.native`). */
41
+ constructor({ nc }) {
42
+ this.#nc = nc
43
+ }
44
+
45
+ /** @type {'nats'} */
46
+ get name() {
47
+ return 'nats'
48
+ }
49
+
50
+ /**
51
+ * 발행 — persist 면 JetStream(영속), 아니면 core pub/sub(비영속).
52
+ * @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ persist?: boolean }} opts
53
+ * @returns {Promise<void>}
54
+ */
55
+ async publish(subject, envelope, { persist = false } = {}) {
56
+ if (!persist) {
57
+ this.#nc.publish(subject, encodeJson(envelope))
58
+ return
59
+ }
60
+ await this.#ensureJetStream()
61
+ await this.#jsc.publish(`${PERSIST_PREFIX}${subject}`, encodeJson(envelope))
62
+ }
63
+
64
+ /**
65
+ * 구독 — persist 면 JetStream ephemeral consumer, 아니면 core 구독. core 는 wildcard·reply 를 native 지원.
66
+ * @param {string} pattern @param {(envelope: import('./contract.js').BusEnvelope, reply?: import('./contract.js').ReplyFn, subject?: string) => any} handler
67
+ * @param {{ persist?: boolean, ordered?: boolean }} opts @returns {Promise<import('./contract.js').Subscription>}
68
+ */
69
+ async subscribe(pattern, handler, { persist = false, ordered = false } = {}) {
70
+ if (persist) return this.#subscribePersistent(pattern, handler, ordered)
71
+ let tail = Promise.resolve()
72
+ const sub = this.#nc.subscribe(pattern, {
73
+ callback: (/** @type {any} */ err, /** @type {any} */ msg) => {
74
+ if (err) return // 구독 에러(드물게)는 NATS 가 별도 보고 — 콜백 인자로 온 err 는 무시 가능(P4: 비치명적).
75
+ const envelope = decodeJson(msg.data)
76
+ // request 로 온 메시지면(reply subject 존재) replyFn 제공. msg.subject = 매칭된 구체 subject(NATS native).
77
+ const reply = msg.reply ? (/** @type {any} */ env) => this.#nc.publish(msg.reply, encodeJson(env)) : undefined
78
+ if (ordered) tail = tail.then(() => handler(envelope, reply, msg.subject))
79
+ else void handler(envelope, reply, msg.subject)
80
+ },
81
+ })
82
+ return {
83
+ unsubscribe: async () => {
84
+ sub.unsubscribe()
85
+ },
86
+ }
87
+ }
88
+
89
+ /**
90
+ * req/reply — NATS native request. 첫 응답 envelope 반환. v3 에러 클래스로 timeout/no-responders 판별.
91
+ * @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ timeout: number }} opts
92
+ * @returns {Promise<import('./contract.js').BusEnvelope>}
93
+ */
94
+ async request(subject, envelope, { timeout }) {
95
+ try {
96
+ const reply = await this.#nc.request(subject, encodeJson(envelope), { timeout })
97
+ return decodeJson(reply.data)
98
+ } catch (err) {
99
+ // v3(ADR-225): timeout/무응답이 에러 클래스. cold path 라 catch 에서 lazy import(모듈은 이미 로드 — 비용 0).
100
+ const { TimeoutError, NoRespondersError, RequestError } = await import('@nats-io/nats-core')
101
+ if (err instanceof TimeoutError) {
102
+ throw new MegaInternalError('bus.request_timeout', `bus request("${subject}") timed out after ${timeout}ms.`, { details: { subject, timeout }, cause: err })
103
+ }
104
+ if (err instanceof NoRespondersError || (err instanceof RequestError && err.isNoResponders())) {
105
+ throw new MegaInternalError('bus.no_responders', `bus request("${subject}"): no responders subscribed.`, { details: { subject }, cause: err })
106
+ }
107
+ throw err
108
+ }
109
+ }
110
+
111
+ /** @returns {Promise<{ driver: string, subscriptions: number }>} 영속 구독 수(core 구독은 NATS 측 관리라 미집계). */
112
+ async stats() {
113
+ return { driver: 'nats', subscriptions: this.#persistSubs.size }
114
+ }
115
+
116
+ /** 정리 — 영속 consumer 정지. nc 는 어댑터 소유라 닫지 않는다. @returns {Promise<void>} */
117
+ async close() {
118
+ for (const sub of this.#persistSubs) sub.stop()
119
+ this.#persistSubs.clear()
120
+ }
121
+
122
+ /**
123
+ * 영속 구독 — 영속 스트림 위에 ephemeral consumer 를 만들고 consume 루프로 배달 + ack.
124
+ * @param {string} pattern @param {Function} handler @param {boolean} ordered
125
+ * @returns {Promise<import('./contract.js').Subscription>}
126
+ */
127
+ async #subscribePersistent(pattern, handler, ordered) {
128
+ await this.#ensureJetStream()
129
+ const filter = `${PERSIST_PREFIX}${pattern}`
130
+ // ephemeral consumer(durable_name 없음 — inactive 시 서버가 자동 삭제). filter 로 패턴 한정.
131
+ const ci = await this.#jsm.consumers.add(PERSIST_STREAM, {
132
+ ack_policy: this.#js.AckPolicy.Explicit,
133
+ filter_subject: filter,
134
+ inactive_threshold: this.#nanos(30_000), // 끊긴 consumer 30s 후 서버 정리.
135
+ })
136
+ const consumer = await this.#jsc.consumers.get(PERSIST_STREAM, ci.name)
137
+ const messages = await consumer.consume()
138
+ let stopped = false
139
+ let tail = Promise.resolve()
140
+ // 배달 루프 — 별도 비동기. envelope 디코드 → 핸들러 → ack. 영속은 request/reply 미지원(reply 없음).
141
+ ;(async () => {
142
+ for await (const msg of messages) {
143
+ if (stopped) break
144
+ const envelope = decodeJson(msg.data)
145
+ // 저장 subject 는 내부 persist prefix 가 붙어 있으니 떼고 넘긴다 → manager 는 user prefix 만 안다.
146
+ const subject = msg.subject.startsWith(PERSIST_PREFIX) ? msg.subject.slice(PERSIST_PREFIX.length) : msg.subject
147
+ if (ordered) tail = tail.then(() => handler(envelope, undefined, subject))
148
+ else void handler(envelope, undefined, subject)
149
+ msg.ack()
150
+ }
151
+ })().catch(() => {
152
+ // consume 루프 종료(stop/연결단절)는 정상 흐름 — unsubscribe/close 가 stop 한다. 비치명적.
153
+ })
154
+ const entry = {
155
+ stop: () => {
156
+ stopped = true
157
+ messages.stop()
158
+ },
159
+ }
160
+ this.#persistSubs.add(entry)
161
+ return {
162
+ unsubscribe: async () => {
163
+ entry.stop()
164
+ this.#persistSubs.delete(entry)
165
+ try {
166
+ await this.#jsm.consumers.delete(PERSIST_STREAM, ci.name)
167
+ } catch (e) {
168
+ // 이미 자동 삭제(inactive_threshold)됐을 수 있음 — not-found 는 무해. 그 외는 전파.
169
+ if (!(e instanceof this.#js.JetStreamApiError && e.code === this.#js.JetStreamApiCodes.ConsumerNotFound)) throw e
170
+ }
171
+ },
172
+ }
173
+ }
174
+
175
+ /** JetStream client/manager lazy 초기화 + 영속 스트림 멱등 보장(최초 persist 사용 시 1회). @returns {Promise<void>} */
176
+ async #ensureJetStream() {
177
+ if (this.#streamEnsured) return
178
+ if (this.#jsInit) return this.#jsInit
179
+ this.#jsInit = (async () => {
180
+ // nanos 는 @nats-io/nats-core, JetStream client/manager·enum 은 @nats-io/jetstream(ADR-225). 병렬 lazy import.
181
+ const [js, core] = await Promise.all([import('@nats-io/jetstream'), import('@nats-io/nats-core')])
182
+ this.#js = js
183
+ this.#nanos = core.nanos
184
+ this.#jsc = js.jetstream(this.#nc)
185
+ this.#jsm = await js.jetstreamManager(this.#nc)
186
+ try {
187
+ await this.#jsm.streams.info(PERSIST_STREAM)
188
+ } catch (e) {
189
+ if (e instanceof js.JetStreamApiError && e.code === js.JetStreamApiCodes.StreamNotFound) {
190
+ await this.#jsm.streams.add({ name: PERSIST_STREAM, subjects: [`${PERSIST_PREFIX}>`], retention: js.RetentionPolicy.Limits, storage: js.StorageType.File })
191
+ } else {
192
+ throw e
193
+ }
194
+ }
195
+ this.#streamEnsured = true
196
+ })()
197
+ try {
198
+ await this.#jsInit
199
+ } finally {
200
+ this.#jsInit = null
201
+ }
202
+ }
203
+ }
@@ -11,7 +11,7 @@
11
11
  * @module core/config-validator
12
12
  */
13
13
  import { MegaConfigError } from '../errors/config-error.js'
14
- import { GLOBAL_ONLY_KEYS, APP_ONLY_KEYS, SHARED_REFERENCE_KEYS } from './scope-registry.js'
14
+ import { GLOBAL_ONLY_KEYS, APP_ONLY_KEYS, SHARED_REFERENCE_KEYS, DUAL_SCOPE_KEYS } from './scope-registry.js'
15
15
  import { checkCompressionConfig } from './ws-compression.js'
16
16
 
17
17
  /**
@@ -81,6 +81,16 @@ export function validateGlobalConfig(globalConfig) {
81
81
  validateWsClusterConfig(globalConfig.wsCluster, globalConfig.services?.buses)
82
82
  }
83
83
 
84
+ // 4c) lock 검증 (ADR-226) — 분산 락 driver 자동폴백 + 기본 옵션. 잘못된 설정은 부팅 시 fail-fast.
85
+ if (globalConfig.lock !== undefined) {
86
+ validateLockConfig(globalConfig.lock, globalConfig.services?.caches)
87
+ }
88
+
89
+ // 4d) bus 검증 (ADR-227) — 메시지 버스 driver 자동폴백 + 기본 옵션. 잘못된 설정은 부팅 시 fail-fast.
90
+ if (globalConfig.bus !== undefined) {
91
+ validateBusConfig(globalConfig.bus, globalConfig.services?.buses)
92
+ }
93
+
84
94
  // 5) jobs / schedules / workers 명시 등록 배열 검증 (M-2 + ADR-124 / 04-data-models §1.1).
85
95
  // GLOBAL_ONLY_KEYS 에 키만 등록돼 있고 shape 검증이 없어, `jobs: SendEmailJob`(배열 잊음)이나
86
96
  // 원소가 클래스 아닌 값이면 흡수 로직이 silent drop 했다. 부팅 시 fail-fast 로 드러낸다.
@@ -212,6 +222,103 @@ function validateWsClusterConfig(wsCluster, buses) {
212
222
  }
213
223
  }
214
224
 
225
+ /** lock.driver 허용값 (ADR-226). */
226
+ const LOCK_DRIVERS = Object.freeze(['auto', 'redis', 'cluster', 'memory'])
227
+
228
+ /**
229
+ * `lock` config 검증 (ADR-226). 분산 락 manager 자동배선의 트리거라 잘못된 설정을 부팅 시 fail-fast 한다.
230
+ * (1) object 여야 한다.
231
+ * (2) `driver` 가 있으면 'auto'|'redis'|'cluster'|'memory' 중 하나.
232
+ * (3) `ttl`/`waitMs` 가 있으면 음 아닌 정수(ttl 은 양의 정수, waitMs 는 0 이상).
233
+ * (4) `fifo` 가 있으면 boolean.
234
+ * (5) `cache` 가 있으면 string + `services.caches` 에 선언된 키(redis driver 가 빌릴 cache alias).
235
+ *
236
+ * @param {any} lock - globalConfig.lock.
237
+ * @param {Record<string, any>|undefined} caches - globalConfig.services.caches.
238
+ * @throws {MegaConfigError} 위 위반 시.
239
+ */
240
+ function validateLockConfig(lock, caches) {
241
+ if (typeof lock !== 'object' || lock === null || Array.isArray(lock)) {
242
+ throw new MegaConfigError('config.lock_invalid', 'lock must be an object ({ driver?, ttl?, waitMs?, fifo?, cache? }).', {
243
+ details: { type: Array.isArray(lock) ? 'array' : typeof lock },
244
+ })
245
+ }
246
+ if (lock.driver !== undefined && !LOCK_DRIVERS.includes(lock.driver)) {
247
+ throw new MegaConfigError('config.lock_driver_invalid', `lock.driver must be one of [${LOCK_DRIVERS.join(', ')}], got '${lock.driver}'.`, {
248
+ details: { driver: lock.driver },
249
+ })
250
+ }
251
+ if (lock.ttl !== undefined && (!Number.isInteger(lock.ttl) || lock.ttl <= 0)) {
252
+ throw new MegaConfigError('config.lock_ttl_invalid', 'lock.ttl must be a positive integer (ms).', { details: { ttl: lock.ttl } })
253
+ }
254
+ if (lock.waitMs !== undefined && (!Number.isInteger(lock.waitMs) || lock.waitMs < 0)) {
255
+ throw new MegaConfigError('config.lock_waitms_invalid', 'lock.waitMs must be an integer >= 0 (ms, 0 = no wait).', { details: { waitMs: lock.waitMs } })
256
+ }
257
+ if (lock.fifo !== undefined && typeof lock.fifo !== 'boolean') {
258
+ throw new MegaConfigError('config.lock_fifo_invalid', 'lock.fifo must be a boolean.', { details: { fifo: lock.fifo } })
259
+ }
260
+ if (lock.cache !== undefined) {
261
+ if (typeof lock.cache !== 'string' || lock.cache.length === 0) {
262
+ throw new MegaConfigError('config.lock_cache_invalid', 'lock.cache must be a non-empty string (a global services.caches key).', { details: { cache: lock.cache } })
263
+ }
264
+ if (!caches?.[lock.cache]) {
265
+ throw new MegaConfigError(
266
+ 'config.lock_cache_not_found',
267
+ `lock.cache '${lock.cache}' is not defined in services.caches. Declared: [${Object.keys(caches ?? {}).join(', ') || '(none)'}].`,
268
+ { details: { cache: lock.cache, declared: Object.keys(caches ?? {}) } },
269
+ )
270
+ }
271
+ }
272
+ }
273
+
274
+ /** bus.driver 허용값 (ADR-227). */
275
+ const BUS_DRIVERS = Object.freeze(['auto', 'nats', 'cluster', 'memory'])
276
+
277
+ /**
278
+ * `bus` config 검증 (ADR-227). 메시지 버스 manager 자동배선의 트리거라 잘못된 설정을 부팅 시 fail-fast 한다.
279
+ * (1) object 여야 한다.
280
+ * (2) `driver` 가 있으면 'auto'|'nats'|'cluster'|'memory' 중 하나.
281
+ * (3) `prefix` 가 있으면 string.
282
+ * (4) `defaultPersist` 가 있으면 boolean.
283
+ * (5) `requestTimeoutMs` 가 있으면 양의 정수.
284
+ * (6) `nats` 가 있으면 string + `services.buses` 에 선언된 키(nats driver 가 빌릴 버스 alias).
285
+ *
286
+ * @param {any} bus - globalConfig.bus.
287
+ * @param {Record<string, any>|undefined} buses - globalConfig.services.buses.
288
+ * @throws {MegaConfigError} 위 위반 시.
289
+ */
290
+ function validateBusConfig(bus, buses) {
291
+ if (typeof bus !== 'object' || bus === null || Array.isArray(bus)) {
292
+ throw new MegaConfigError('config.bus_invalid', 'bus must be an object ({ driver?, nats?, prefix?, defaultPersist?, requestTimeoutMs? }).', {
293
+ details: { type: Array.isArray(bus) ? 'array' : typeof bus },
294
+ })
295
+ }
296
+ if (bus.driver !== undefined && !BUS_DRIVERS.includes(bus.driver)) {
297
+ throw new MegaConfigError('config.bus_driver_invalid', `bus.driver must be one of [${BUS_DRIVERS.join(', ')}], got '${bus.driver}'.`, { details: { driver: bus.driver } })
298
+ }
299
+ if (bus.prefix !== undefined && typeof bus.prefix !== 'string') {
300
+ throw new MegaConfigError('config.bus_prefix_invalid', 'bus.prefix must be a string (subject namespace, e.g. "app.").', { details: { prefix: bus.prefix } })
301
+ }
302
+ if (bus.defaultPersist !== undefined && typeof bus.defaultPersist !== 'boolean') {
303
+ throw new MegaConfigError('config.bus_persist_invalid', 'bus.defaultPersist must be a boolean.', { details: { defaultPersist: bus.defaultPersist } })
304
+ }
305
+ if (bus.requestTimeoutMs !== undefined && (!Number.isInteger(bus.requestTimeoutMs) || bus.requestTimeoutMs <= 0)) {
306
+ throw new MegaConfigError('config.bus_timeout_invalid', 'bus.requestTimeoutMs must be a positive integer (ms).', { details: { requestTimeoutMs: bus.requestTimeoutMs } })
307
+ }
308
+ if (bus.nats !== undefined) {
309
+ if (typeof bus.nats !== 'string' || bus.nats.length === 0) {
310
+ throw new MegaConfigError('config.bus_nats_invalid', 'bus.nats must be a non-empty string (a global services.buses key).', { details: { nats: bus.nats } })
311
+ }
312
+ if (!buses?.[bus.nats]) {
313
+ throw new MegaConfigError(
314
+ 'config.bus_nats_not_found',
315
+ `bus.nats '${bus.nats}' is not defined in services.buses. Declared: [${Object.keys(buses ?? {}).join(', ') || '(none)'}].`,
316
+ { details: { nats: bus.nats, declared: Object.keys(buses ?? {}) } },
317
+ )
318
+ }
319
+ }
320
+ }
321
+
215
322
  /**
216
323
  * `jobs`/`schedules`/`workers` 같은 "클래스(함수) 배열" config 키를 검증한다 (M-2/ADR-124). 미정의면
217
324
  * 통과(선택 키). 정의됐으면 (1) 배열이어야 하고 (2) 모든 원소가 함수(클래스)여야 한다 — 위반 시 부팅 fail-fast.
@@ -260,6 +367,7 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
260
367
  // 1) 알 수 없는 키 + 잘못된 스코프
261
368
  for (const key of Object.keys(appConfig)) {
262
369
  if (APP_ONLY_KEYS.includes(key)) continue
370
+ if (DUAL_SCOPE_KEYS.includes(key)) continue // lock/bus 는 글로벌·앱 양쪽 허용(ADR-229) — 아래서 shape 검증.
263
371
  if (GLOBAL_ONLY_KEYS.includes(key)) {
264
372
  throw new MegaConfigError(
265
373
  'config.wrong_scope',
@@ -290,6 +398,15 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
290
398
  )
291
399
  }
292
400
 
401
+ // 2a) 앱별 lock/bus(ADR-229) — 글로벌과 같은 shape 규칙. cache/nats 는 **글로벌 services** 의 키를 참조한다
402
+ // (services 는 글로벌 정의라 앱 lock.cache/bus.nats 도 globalConfig.services 를 본다).
403
+ if (appConfig.lock !== undefined) {
404
+ validateLockConfig(appConfig.lock, globalConfig?.services?.caches)
405
+ }
406
+ if (appConfig.bus !== undefined) {
407
+ validateBusConfig(appConfig.bus, globalConfig?.services?.buses)
408
+ }
409
+
293
410
  // 2b) bridgeHub(WS Hub 브릿지, ADR-065/176) 검증 + wsCluster 상호배타.
294
411
  // 클러스터 전송은 앱당 **하나**다 — bridgeHub(WS Hub) 와 global wsCluster(NATS) 를 동시에 쓰면
295
412
  // app.broadcast 가 양쪽으로 나가 이중 전파된다. 부팅 시 fail-fast 로 막는다(boot 가 둘 중 하나만 배선).
@@ -18,6 +18,8 @@
18
18
  */
19
19
  import { MegaConfigError } from '../errors/config-error.js'
20
20
  import * as AdapterManager from '../adapters/adapter-manager.js'
21
+ import { getLockManager, attachLockApi } from './lock/index.js'
22
+ import { getBusManager, attachBusApi } from './bus/index.js'
21
23
  import { contextProxy as workersContext, PROXY_PROTOCOL_KEYS } from './workers-manager.js'
22
24
  import { tracer as megaTracer } from '../lib/mega-tracing.js'
23
25
 
@@ -160,7 +162,18 @@ export function buildAdapterAccessors(aliasMaps = {}, appName = '(unknown)') {
160
162
  return AdapterManager.get(domain, globalKey) // 별명은 있으나 인스턴스 미등록이면 여기서 throw
161
163
  }
162
164
  }
163
- return { db: make('db'), cache: make('cache'), bus: make('bus'), lock: make('lock') }
165
+ // lock accessor 종전대로 `(alias) => MegaLockAdapter` 콜러블이되, 분산 락 manager 가 설정돼 있으면
166
+ // 거기에 `.with/.acquire/.tryAcquire/...` 사용자 API 를 얹는다(ADR-226). `ctx.lock(alias)`(스케줄러 등
167
+ // 기존 사용처)와 `ctx.lock.with(...)`(신규)이 한 콜러블에 공존한다.
168
+ const lock = make('lock')
169
+ const manager = getLockManager()
170
+ if (manager) attachLockApi(/** @type {any} */ (lock), manager)
171
+ // bus accessor 도 동일 — `ctx.bus(alias)`(설정형 어댑터)에 `.emit/.on/.off/.request/.with` 메시지 버스
172
+ // 사용자 API 를 얹는다(ADR-227). 콜러블 공존(락 ADR-226 과 같은 패턴).
173
+ const bus = make('bus')
174
+ const busManager = getBusManager()
175
+ if (busManager) attachBusApi(/** @type {any} */ (bus), busManager)
176
+ return { db: make('db'), cache: make('cache'), bus, lock }
164
177
  }
165
178
 
166
179
  /**
package/src/core/index.js CHANGED
@@ -9,6 +9,8 @@ export { MegaCluster } from './mega-cluster.js'
9
9
  export { loadRoutes } from './routes-loader.js'
10
10
  // 중앙 부팅 orchestrator (ADR-123)
11
11
  export { bootApp, buildBootContext } from './boot.js'
12
+ // ctx 없는 영역(백그라운드·외부 콜백·테스트)의 booted MegaApp 접근점 (ADR-228)
13
+ export { getApp, hasApp } from './app-registry.js'
12
14
  export { wrapEnvelope, errorEnvelope, synthesizeEnvelopeResponseSchema, ENVELOPE_MARK } from './envelope.js'
13
15
  // HTTP 라이프사이클 Pipeline — before/transform/after 합성 정본 (ADR-185)
14
16
  export { buildHttpPipeline, wrapPreHandler, composeTransform, composeAfter } from './pipeline.js'