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
@@ -0,0 +1,276 @@
1
+ // @ts-check
2
+ /**
3
+ * RedisLockDriver — redis 기반 **진짜 분산** 락 driver (ADR-226).
4
+ *
5
+ * 멀티 프로세스·멀티 노드에서 상호배제를 보장하는 유일한 driver. 단일 redis 노드에 대한 Redlock 단일-인스턴스
6
+ * 알고리즘(`SET key token NX PX ttl` + 소유 토큰 + Lua 원자 해제)을 raw ioredis 클라이언트로 직접 구현한다.
7
+ * 기존 {@link import('../../adapters/redlock-adapter.js')}(redlock 라이브러리, `ctx.lock(alias)`)와 별개 —
8
+ * 이쪽은 fence/FIFO/watchdog 를 위해 명령을 직접 제어해야 해서 raw 클라이언트를 쓴다(라이브러리 미경유).
9
+ * redis 클라이언트는 cache 어댑터가 소유한 것을 **빌려** 쓴다(연결 생애주기는 cache 가 관리 — close 에서
10
+ * quit 하지 않음). 단, Pub/Sub 구독은 별도 연결이 필요하므로 `duplicate()` 한 구독 연결만 이 driver 가 소유한다.
11
+ *
12
+ * # 키 네임스페이스(`mega:lock:` 접두)
13
+ * - `…:v:{key}` 락 값(member=token, PX ttl) · `…:f:{key}` fence 카운터(INCR) ·
14
+ * `…:w:{key}` FIFO 대기열 zset · `…:c:{key}` 해제 알림 Pub/Sub 채널.
15
+ *
16
+ * # 대기(waitMs) — Pub/Sub 깨우기
17
+ * 경합 시 폴링 대신 해제 채널을 구독해 깨어난다. 구독은 `PSUBSCRIBE …:c:*` 하나로 모든 키를 받고
18
+ * 채널에서 key 를 역산해 해당 대기자만 깨운다. 깨우기 누락(메시지 유실) 대비로 짧은 안전 폴링도 병행한다.
19
+ *
20
+ * # fence / FIFO / watchdog
21
+ * - fence: 획득 직후 `INCR …:f:{key}` — 단조 증가 토큰(외부 시스템의 stale-writer 차단). 카운터는 영속.
22
+ * - FIFO: {@link import('./fifo-waitlist.js').FifoWaitlist}(zset) — head 가 됐을 때만 SET 시도.
23
+ * - watchdog(extendable): manager 가 `extend` 를 주기 호출(Lua PEXPIRE) — driver 는 1회 연장만 책임.
24
+ *
25
+ * driver 계약은 {@link import('./contract.js').LockDriver} 를 따른다(typedef — 런타임 implements 아님).
26
+ * @module core/lock/redis-lock
27
+ */
28
+ import { generateToken } from './contract.js'
29
+ import { FifoWaitlist } from './fifo-waitlist.js'
30
+
31
+ /** 키 접두 — 다른 redis 사용처(cache 등)와 충돌 방지. */
32
+ const PREFIX = 'mega:lock'
33
+ /** Pub/Sub 깨우기를 놓쳐도 진행하도록 하는 안전 폴링 간격(ms). */
34
+ const SAFETY_POLL_MS = 50
35
+
36
+ /** 소유 토큰일 때만 삭제 + 해제 알림(원자). KEYS=[lock, channel] ARGV=[token]. @type {string} */
37
+ const RELEASE_LUA = `
38
+ if redis.call('get', KEYS[1]) == ARGV[1] then
39
+ redis.call('del', KEYS[1])
40
+ redis.call('publish', KEYS[2], '1')
41
+ return 1
42
+ else
43
+ return 0
44
+ end`
45
+
46
+ /** 소유 토큰일 때만 PEXPIRE 연장(원자). KEYS=[lock] ARGV=[token, ttl]. @type {string} */
47
+ const EXTEND_LUA = `
48
+ if redis.call('get', KEYS[1]) == ARGV[1] then
49
+ return redis.call('pexpire', KEYS[1], ARGV[2])
50
+ else
51
+ return 0
52
+ end`
53
+
54
+ export class RedisLockDriver {
55
+ /** @type {any} ioredis 호환 명령 클라이언트(cache 어댑터 소유 — 빌림). */ #client
56
+ /** @type {any} duplicate() 한 구독 연결(이 driver 소유 — close 에서 quit). */ #sub = null
57
+ /** @type {(() => any) | null} 구독 연결 생성자(테스트 주입). */ #createSub
58
+ /** @type {FifoWaitlist} */ #waitlist
59
+ /** @type {Map<string, Set<() => void>>} key → 해제 알림 대기 콜백. */ #wakers = new Map()
60
+ /** @type {boolean} PSUBSCRIBE 완료 여부(최초 대기 때 1회). */ #subscribed = false
61
+ /** @type {Promise<void> | null} 구독 진행 중 락(중복 구독 방지). */ #subscribing = null
62
+
63
+ /**
64
+ * @param {{ client: any, createSubscriber?: () => any }} opts
65
+ * - `client` ioredis 호환 클라이언트(`set`/`eval`/`incr`/`zadd`… 보유). cache 어댑터의 `.native`.
66
+ * - `createSubscriber` Pub/Sub 구독 연결 생성자(기본 `client.duplicate()`). 테스트 주입용.
67
+ */
68
+ constructor({ client, createSubscriber }) {
69
+ this.#client = client
70
+ this.#createSub = createSubscriber ?? (() => client.duplicate())
71
+ this.#waitlist = new FifoWaitlist(client, { zkey: (key) => `${PREFIX}:w:${key}`, seqKey: `${PREFIX}:seq` })
72
+ }
73
+
74
+ /** @type {'redis'} */
75
+ get name() {
76
+ return 'redis'
77
+ }
78
+
79
+ /** @param {string} key @returns {string} 락 값 키. */
80
+ #lockKey(key) {
81
+ return `${PREFIX}:v:${key}`
82
+ }
83
+
84
+ /** @param {string} key @returns {string} 해제 알림 채널. */
85
+ #chan(key) {
86
+ return `${PREFIX}:c:${key}`
87
+ }
88
+
89
+ /**
90
+ * 락 획득 — fifo 면 대기열 head 일 때만, 아니면 즉시 SET 후 경합 시 Pub/Sub 대기.
91
+ * @param {string} key @param {import('./contract.js').NormalizedLockOpts} opts
92
+ * @returns {Promise<import('./contract.js').DriverLock | null>}
93
+ */
94
+ async acquire(key, { ttl, waitMs, fifo, fence }) {
95
+ const token = generateToken()
96
+ const acquired = fifo
97
+ ? await this.#acquireFifo(key, token, ttl, waitMs)
98
+ : await this.#acquirePlain(key, token, ttl, waitMs)
99
+ if (!acquired) return null
100
+ if (!fence) return { token }
101
+ // fence: 락을 쥔 상태에서만 INCR — 단조 증가 보장(외부 시스템 stale-writer 차단용).
102
+ const fenceVal = Number(await this.#client.incr(`${PREFIX}:f:${key}`))
103
+ return { token, fence: fenceVal }
104
+ }
105
+
106
+ /**
107
+ * 비-FIFO 획득 — SET NX 즉시 시도, 실패 시 waitMs 안에서 해제 알림 받으며 재시도.
108
+ * @param {string} key @param {string} token @param {number} ttl @param {number} waitMs @returns {Promise<boolean>}
109
+ */
110
+ async #acquirePlain(key, token, ttl, waitMs) {
111
+ if (await this.#trySet(key, token, ttl)) return true
112
+ if (waitMs === 0) return false
113
+ const deadline = Date.now() + waitMs
114
+ while (Date.now() < deadline) {
115
+ await this.#waitForRelease(key, Math.min(SAFETY_POLL_MS, deadline - Date.now()))
116
+ if (await this.#trySet(key, token, ttl)) return true
117
+ }
118
+ return false
119
+ }
120
+
121
+ /**
122
+ * FIFO 획득 — 대기열 합류 후, 내가 head 이고 락이 비었을 때만 SET. waitMs 초과 시 이탈하고 실패.
123
+ * @param {string} key @param {string} token @param {number} ttl @param {number} waitMs @returns {Promise<boolean>}
124
+ */
125
+ async #acquireFifo(key, token, ttl, waitMs) {
126
+ const deadline = Date.now() + waitMs
127
+ // member 에 deadline 을 실어 합류 — 죽은 대기자를 head reaping 으로 청소할 수 있게(fifo-waitlist).
128
+ // waitMs=0(tryAcquire)이라도 head 검사 위해 잠깐 합류했다 finally 에서 바로 이탈.
129
+ const member = await this.#waitlist.join(key, token, deadline)
130
+ try {
131
+ for (;;) {
132
+ if (await this.#waitlist.isHead(key, token, Date.now())) {
133
+ if (await this.#trySet(key, token, ttl)) return true
134
+ }
135
+ if (Date.now() >= deadline) return false
136
+ await this.#waitForRelease(key, Math.min(SAFETY_POLL_MS, deadline - Date.now()))
137
+ }
138
+ } finally {
139
+ await this.#waitlist.leave(key, member)
140
+ }
141
+ }
142
+
143
+ /** SET NX PX 1회 시도. @param {string} key @param {string} token @param {number} ttl @returns {Promise<boolean>} */
144
+ async #trySet(key, token, ttl) {
145
+ const res = await this.#client.set(this.#lockKey(key), token, 'PX', ttl, 'NX')
146
+ return res === 'OK'
147
+ }
148
+
149
+ /**
150
+ * 해제 알림(Pub/Sub)을 timeoutMs 안에서 기다린다 — 알림 오면 즉시, 아니면 timeout 후 resolve(안전 폴링).
151
+ * @param {string} key @param {number} timeoutMs @returns {Promise<void>}
152
+ */
153
+ async #waitForRelease(key, timeoutMs) {
154
+ await this.#ensureSubscribed()
155
+ await new Promise((resolve) => {
156
+ let done = false
157
+ const finish = () => {
158
+ if (done) return
159
+ done = true
160
+ clearTimeout(timer)
161
+ const set = this.#wakers.get(key)
162
+ if (set) {
163
+ set.delete(waker)
164
+ if (set.size === 0) this.#wakers.delete(key)
165
+ }
166
+ resolve(undefined)
167
+ }
168
+ const waker = () => finish()
169
+ const timer = setTimeout(finish, Math.max(1, timeoutMs))
170
+ timer.unref?.()
171
+ const set = this.#wakers.get(key) ?? new Set()
172
+ set.add(waker)
173
+ this.#wakers.set(key, set)
174
+ })
175
+ }
176
+
177
+ /** 최초 대기 시 구독 연결 1회 PSUBSCRIBE. 동시 진입은 #subscribing 으로 직렬화. @returns {Promise<void>} */
178
+ async #ensureSubscribed() {
179
+ if (this.#subscribed) return
180
+ if (this.#subscribing) return this.#subscribing
181
+ this.#subscribing = (async () => {
182
+ const sub = this.#createSub()
183
+ sub.on('pmessage', (/** @type {string} */ _pattern, /** @type {string} */ channel) => {
184
+ // 채널 …:c:{key} 에서 key 역산 → 해당 대기자 전부 깨움(head 가 재시도).
185
+ const prefix = `${PREFIX}:c:`
186
+ if (!channel.startsWith(prefix)) return
187
+ const key = channel.slice(prefix.length)
188
+ const set = this.#wakers.get(key)
189
+ if (!set) return
190
+ for (const waker of [...set]) waker()
191
+ })
192
+ await sub.psubscribe(`${PREFIX}:c:*`)
193
+ this.#sub = sub
194
+ this.#subscribed = true
195
+ })()
196
+ try {
197
+ await this.#subscribing
198
+ } finally {
199
+ this.#subscribing = null
200
+ }
201
+ }
202
+
203
+ /**
204
+ * 해제 — 소유 토큰일 때만 DEL + 해제 알림(Lua 원자).
205
+ * @param {string} key @param {string} token @returns {Promise<boolean>}
206
+ */
207
+ async release(key, token) {
208
+ const res = await this.#client.eval(RELEASE_LUA, 2, this.#lockKey(key), this.#chan(key), token)
209
+ return Number(res) === 1
210
+ }
211
+
212
+ /**
213
+ * 연장 — 소유 토큰일 때만 PEXPIRE(Lua 원자).
214
+ * @param {string} key @param {string} token @param {number} ttl @returns {Promise<boolean>}
215
+ */
216
+ async extend(key, token, ttl) {
217
+ const res = await this.#client.eval(EXTEND_LUA, 1, this.#lockKey(key), token, String(ttl))
218
+ return Number(res) === 1
219
+ }
220
+
221
+ /** 강제 해제(관리용, 토큰 무관) — DEL + 해제 알림(대기자 깨움). @param {string} key @returns {Promise<void>} */
222
+ async forceRelease(key) {
223
+ await this.#client.del(this.#lockKey(key))
224
+ await this.#client.publish(this.#chan(key), '1')
225
+ }
226
+
227
+ /**
228
+ * 통계(best-effort) — `…:v:*`/`…:w:*` 키를 SCAN 으로 센다. O(키 수) 라 운영 관측용(고빈도 호출 비권장).
229
+ * @returns {Promise<{ driver: string, active: number, waiting: number }>}
230
+ */
231
+ async stats() {
232
+ const active = await this.#scanCount(`${PREFIX}:v:*`)
233
+ let waiting = 0
234
+ for (const wkey of await this.#scanKeys(`${PREFIX}:w:*`)) {
235
+ waiting += Number(await this.#client.zcard(wkey))
236
+ }
237
+ return { driver: 'redis', active, waiting }
238
+ }
239
+
240
+ /** MATCH 패턴 키 수를 SCAN 으로 센다. @param {string} match @returns {Promise<number>} */
241
+ async #scanCount(match) {
242
+ return (await this.#scanKeys(match)).length
243
+ }
244
+
245
+ /** MATCH 패턴 키를 SCAN 으로 모은다(커서 순회). @param {string} match @returns {Promise<string[]>} */
246
+ async #scanKeys(match) {
247
+ /** @type {string[]} */
248
+ const keys = []
249
+ let cursor = '0'
250
+ do {
251
+ const [next, batch] = await this.#client.scan(cursor, 'MATCH', match, 'COUNT', 100)
252
+ cursor = next
253
+ keys.push(...batch)
254
+ } while (cursor !== '0')
255
+ return keys
256
+ }
257
+
258
+ /** 정리 — 구독 연결만 quit(명령 클라이언트는 cache 소유라 건드리지 않음). @returns {Promise<void>} */
259
+ async close() {
260
+ for (const set of this.#wakers.values()) for (const waker of [...set]) waker()
261
+ this.#wakers.clear()
262
+ if (this.#sub) {
263
+ try {
264
+ await this.#sub.punsubscribe(`${PREFIX}:c:*`)
265
+ await this.#sub.quit()
266
+ } catch (e) {
267
+ // graceful quit 실패 → 강제 disconnect 로 소켓이라도 끊고, 에러는 wrap 해 상위로(매니저가 로깅).
268
+ if (typeof this.#sub.disconnect === 'function') this.#sub.disconnect()
269
+ throw new Error(`redis lock subscriber close failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e })
270
+ } finally {
271
+ this.#sub = null
272
+ this.#subscribed = false
273
+ }
274
+ }
275
+ }
276
+ }
@@ -620,6 +620,35 @@ export class MegaApp {
620
620
  return this._adapterAccessors
621
621
  }
622
622
 
623
+ // ── ctx 없는 영역(getApp())용 표면 (ADR-228) ─────────────────────────────────
624
+ // 요청 ctx 가 없는 곳(백그라운드 타이머·외부 콜백·테스트)에서 ctx.lock/ctx.bus 와 **동일 표면**으로 쓰도록
625
+ // process-level 자원만 노출한다. 요청-스코프(req/user/session/요청 locale t)는 ctx 전용이라 여기 없다.
626
+
627
+ /** 분산 락 사용자 API(ADR-226) — `ctx.lock` 과 같은 process manager(콜러블 + .with/.acquire/...). @returns {any} */
628
+ get lock() {
629
+ return this._adapterAccessors.lock
630
+ }
631
+
632
+ /** 메시지 버스 사용자 API(ADR-227) — `ctx.bus` 와 같은 process manager(콜러블 + .emit/.on/.request/...). @returns {any} */
633
+ get bus() {
634
+ return this._adapterAccessors.bus
635
+ }
636
+
637
+ /** 앱 별명 → 공유 DB 어댑터(ADR-102). 미선언 별명은 throw. @param {string} alias @returns {import('../adapters/mega-adapter.js').MegaAdapter} */
638
+ db(alias) {
639
+ return this._adapterAccessors.db(alias)
640
+ }
641
+
642
+ /** 앱 별명 → 공유 캐시 어댑터(ADR-102). 미선언 별명은 throw. @param {string} alias @returns {import('../adapters/mega-adapter.js').MegaAdapter} */
643
+ cache(alias) {
644
+ return this._adapterAccessors.cache(alias)
645
+ }
646
+
647
+ /** 앱 로거(pino, ADR-141) — `ctx.log` 의 요청 바인딩 없는 베이스 로거. @returns {any} */
648
+ get log() {
649
+ return this.fastify.log
650
+ }
651
+
623
652
  /**
624
653
  * 이 앱의 서비스 레지스트리(name → 클래스, ADR-148). 요청 ctx 빌더가 `ctx.services.<name>` lazy DI 의
625
654
  * 클래스 lookup 출처로 읽는다. 부팅 orchestrator 가 `setServiceRegistry` 로 채운다.
@@ -330,7 +330,7 @@ async function generateInner(projectRoot, opts, lock) {
330
330
  const allCandidates = diffs.flatMap((d) => d.candidates)
331
331
  if (allCandidates.length > 0 && !check) {
332
332
  /** @type {Record<string, Record<string, string>> | null} */
333
- let mapping = null
333
+ let mapping
334
334
  if (renames !== undefined) {
335
335
  mapping = parseRenamesSpec(renames, allCandidates)
336
336
  } else {
@@ -127,7 +127,7 @@ export function acquireGenerateLock(projectRoot) {
127
127
  if (/** @type {any} */ (cleanupErr).code !== 'ENOENT') throw cleanupErr
128
128
  }
129
129
  if (/** @type {any} */ (err).code !== 'EEXIST') throw err
130
- let holder = ''
130
+ let holder
131
131
  try {
132
132
  holder = readFileSync(file, 'utf8').trim()
133
133
  } catch {
@@ -12,6 +12,8 @@ export const GLOBAL_ONLY_KEYS = Object.freeze([
12
12
  'server', // port, cluster, sessionSecret
13
13
  'wsHub', // mega ws-hub 명령용 (ADR-068)
14
14
  'wsCluster', // NATS 기반 WS 클러스터 broadcast/roster 자동배선 (ADR-176)
15
+ 'lock', // 분산 락 driver 자동폴백 + 기본 옵션 — ctx.lock.with/.acquire (ADR-226)
16
+ 'bus', // 메시지 버스 driver 자동폴백 + 기본 옵션 — ctx.bus.emit/.on/.request (ADR-227)
15
17
  'logger', // 전역 로거 sinks
16
18
  'apps', // 활성 앱 whitelist (ADR-066)
17
19
  'asp', // masterSecret 등 시크릿
@@ -50,5 +52,12 @@ export const APP_ONLY_KEYS = Object.freeze([
50
52
  /** Shared-Reference 키 — 전역 services 의 키만 참조 가능 */
51
53
  export const SHARED_REFERENCE_KEYS = Object.freeze(['databases', 'caches', 'buses'])
52
54
 
55
+ /**
56
+ * Dual-scope 키 — 글로벌(mega.config.js)과 앱(app.config.js) **양쪽**에 정의 가능 (ADR-229).
57
+ * 글로벌은 fallback, 앱별 정의가 있으면 그 앱은 자기 manager 를 쓴다(앱별 lock/bus backend 분리). GLOBAL_ONLY_KEYS
58
+ * 에도 그대로 남겨(글로벌 정의 허용) 두고, validateAppConfig 가 이 목록을 wrong_scope 예외로 둔다.
59
+ */
60
+ export const DUAL_SCOPE_KEYS = Object.freeze(['lock', 'bus'])
61
+
53
62
  export const ALL_GLOBAL_KEYS = Object.freeze(GLOBAL_ONLY_KEYS)
54
63
  export const ALL_APP_KEYS = Object.freeze(APP_ONLY_KEYS)
@@ -65,7 +65,7 @@ const rule = {
65
65
  },
66
66
 
67
67
  create(context) {
68
- const filename = context.filename ?? context.getFilename?.() ?? ''
68
+ const filename = context.filename ?? ''
69
69
  if (!isControllerOrRouteFile(filename)) return {}
70
70
 
71
71
  /**
@@ -77,7 +77,7 @@ const rule = {
77
77
  context.report({
78
78
  node,
79
79
  messageId: 'direct',
80
- data: { source: sourceValue },
80
+ data: { source: String(sourceValue) },
81
81
  })
82
82
  }
83
83
  }
package/src/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  export { MegaApp, MegaServer, Router, loadAndValidateConfig, loadRoutes } from './core/index.js'
3
3
  // 중앙 부팅 orchestrator + CLI (ADR-123)
4
4
  export { bootApp, buildBootContext } from './core/index.js'
5
+ // ctx 없는 영역(백그라운드 타이머·외부 SDK 콜백·테스트)에서 booted 앱의 lock/bus/db/cache 접근 (ADR-228)
6
+ export { getApp, hasApp } from './core/index.js'
5
7
  export { runCli, parseArgs, runWorkerHost, runSchedulerHost, dispatchPluginCommand, USAGE } from './cli/index.js'
6
8
  export { MegaService } from './core/index.js'
7
9
  export { MegaCluster } from './core/index.js'