mega-framework 0.1.7 → 0.1.9

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 (95) hide show
  1. package/README.md +9 -0
  2. package/package.json +3 -3
  3. package/sample/crud/.env +9 -0
  4. package/sample/crud/.env.example +9 -0
  5. package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
  6. package/sample/crud/apps/main/locales/server/en.json +12 -1
  7. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  8. package/sample/crud/apps/main/routes/upload.js +20 -1
  9. package/sample/crud/apps/main/services/guide-service.js +4 -3
  10. package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
  11. package/sample/crud/apps/main/views/upload/index.ejs +4 -1
  12. package/sample/crud/docs/guide/01-cli.md +587 -0
  13. package/sample/crud/docs/guide/02-router-controller.md +497 -0
  14. package/sample/crud/docs/guide/03-service-model-db.md +929 -0
  15. package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
  16. package/sample/crud/docs/guide/05-scheduler-job-worker.md +400 -0
  17. package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
  18. package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
  19. package/sample/crud/docs/guide/08-observability.md +373 -0
  20. package/sample/crud/mega.config.js +7 -0
  21. package/sample/crud/package.json +2 -2
  22. package/sample/crud/scripts/start-ws-hub.sh +18 -4
  23. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbnwq5v-d2125aa8.txt" +1 -0
  24. package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbo0nbf-842b6135.txt" +1 -0
  25. package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
  26. package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
  27. package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
  28. package/sample/crud/var/uploads//341/204/200/341/205/247/341/206/274/341/204/200/341/205/265/341/204/211/341/205/265/341/206/257/341/204/214/341/205/245/341/206/250/341/204/214/341/205/263/341/206/274/341/204/206/341/205/247/341/206/274/341/204/211/341/205/245-mqbo5yxh-5288d8ef.pdf +0 -0
  29. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  30. package/src/adapters/adapter-options.js +14 -3
  31. package/src/adapters/file-adapter.js +9 -5
  32. package/src/adapters/file-session-adapter.js +4 -3
  33. package/src/adapters/maria-adapter.js +7 -4
  34. package/src/adapters/mega-cache-adapter.js +83 -6
  35. package/src/adapters/mega-db-adapter.js +4 -1
  36. package/src/adapters/mongo-adapter.js +21 -7
  37. package/src/adapters/postgres-adapter.js +8 -4
  38. package/src/adapters/redis-adapter.js +7 -3
  39. package/src/adapters/sqlite-adapter.js +6 -2
  40. package/src/cli/commands/console-cmd.js +3 -1
  41. package/src/cli/commands/scaffold.js +38 -2
  42. package/src/cli/generators/index.js +58 -1
  43. package/src/cli/index.js +88 -59
  44. package/src/cli/watch.js +188 -0
  45. package/src/core/ajv-mapper.js +3 -1
  46. package/src/core/ctx-builder.js +59 -1
  47. package/src/core/envelope.js +9 -2
  48. package/src/core/hub-link.js +24 -14
  49. package/src/core/index.js +1 -1
  50. package/src/core/mega-app.js +55 -45
  51. package/src/core/pipeline.js +8 -6
  52. package/src/core/scope-registry.js +1 -0
  53. package/src/core/security.js +3 -3
  54. package/src/core/session-store.js +14 -1
  55. package/src/core/ws-presence.js +17 -5
  56. package/src/core/ws-roster.js +49 -10
  57. package/src/core/ws-upgrade.js +105 -0
  58. package/src/lib/mega-circuit-breaker.js +5 -3
  59. package/src/lib/mega-health.js +10 -0
  60. package/src/lib/mega-job-queue.js +53 -13
  61. package/src/lib/mega-job.js +8 -1
  62. package/src/lib/mega-metrics.js +28 -1
  63. package/src/lib/mega-plugin.js +2 -2
  64. package/src/lib/mega-worker.js +28 -5
  65. package/src/lib/ws-hub.js +90 -9
  66. package/templates/adr/code.tpl +23 -0
  67. package/types/adapters/adapter-options.d.ts +2 -0
  68. package/types/adapters/file-adapter.d.ts +12 -1
  69. package/types/adapters/file-session-adapter.d.ts +4 -2
  70. package/types/adapters/maria-adapter.d.ts +5 -3
  71. package/types/adapters/mega-cache-adapter.d.ts +27 -1
  72. package/types/adapters/mega-db-adapter.d.ts +4 -1
  73. package/types/adapters/mongo-adapter.d.ts +13 -2
  74. package/types/adapters/postgres-adapter.d.ts +4 -2
  75. package/types/adapters/redis-adapter.d.ts +8 -0
  76. package/types/adapters/sqlite-adapter.d.ts +8 -2
  77. package/types/cli/generators/index.d.ts +11 -1
  78. package/types/cli/index.d.ts +12 -27
  79. package/types/cli/watch.d.ts +59 -0
  80. package/types/core/ctx-builder.d.ts +23 -0
  81. package/types/core/hub-link.d.ts +3 -1
  82. package/types/core/index.d.ts +1 -1
  83. package/types/core/mega-app.d.ts +1 -1
  84. package/types/core/pipeline.d.ts +2 -1
  85. package/types/core/security.d.ts +3 -3
  86. package/types/core/session-store.d.ts +7 -0
  87. package/types/core/ws-roster.d.ts +13 -1
  88. package/types/core/ws-upgrade.d.ts +29 -0
  89. package/types/lib/mega-circuit-breaker.d.ts +4 -2
  90. package/types/lib/mega-health.d.ts +7 -0
  91. package/types/lib/mega-job-queue.d.ts +16 -4
  92. package/types/lib/mega-job.d.ts +8 -1
  93. package/types/lib/mega-plugin.d.ts +1 -1
  94. package/types/lib/mega-worker.d.ts +3 -1
  95. package/types/lib/ws-hub.d.ts +27 -2
@@ -73,7 +73,14 @@ export class MegaJob {
73
73
  /** @type {string|undefined} bus 별명(`ctx.bus(alias)`). 워커 배선이 nc 해석에 사용. */
74
74
  static bus = undefined
75
75
 
76
- /** @type {number} 동시 처리 메시지 수(consumer max_ack_pending). 기본 1(순차·안전). */
76
+ /**
77
+ * @type {number} 동시 처리 메시지 수. 기본 1(순차·안전).
78
+ *
79
+ * ⚠️ 이 값은 durable consumer 의 `max_ack_pending` 으로 들어가므로 **워커 그룹(같은 subject 를
80
+ * 소비하는 모든 인스턴스) 전체의 합산 in-flight 상한**이다 — `mega worker` 인스턴스를 늘려도
81
+ * 합산 동시 처리는 이 값을 넘지 못한다. 처리량을 늘리려면 인스턴스 증설과 **함께** concurrency 를
82
+ * 키워야 한다(실측: c=1→c=32 에서 처리량 ~10배).
83
+ */
77
84
  static concurrency = 1
78
85
 
79
86
  /** @type {number} run 실패 시 **추가** 재시도 횟수(첫 시도 제외). 기본 3(OQ-012). */
@@ -36,6 +36,7 @@
36
36
  * @see https://opentelemetry.io/docs/specs/otel/metrics/ (OTel metrics)
37
37
  * @see https://prometheus.io/docs/instrumenting/exposition_formats/ (Prometheus 텍스트 포맷)
38
38
  */
39
+ import { getHeapStatistics } from 'node:v8'
39
40
  import { MeterProvider } from '@opentelemetry/sdk-metrics'
40
41
  import { PrometheusExporter, PrometheusSerializer } from '@opentelemetry/exporter-prometheus'
41
42
  import { buildOtelResource } from './otel-resource.js'
@@ -547,7 +548,7 @@ function buildInstruments(meter) {
547
548
  */
548
549
  function registerSystemGauges(meter) {
549
550
  const memory = meter.createObservableGauge('mega_process_memory_bytes', {
550
- description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external 라벨.',
551
+ description: '프로세스 메모리 사용량(바이트) — kind=rss|heap_used|heap_total|external|array_buffers 라벨.',
551
552
  unit: 'By',
552
553
  })
553
554
  memory.addCallback((result) => {
@@ -556,6 +557,32 @@ function registerSystemGauges(meter) {
556
557
  result.observe(mu.heapUsed, { kind: 'heap_used' })
557
558
  result.observe(mu.heapTotal, { kind: 'heap_total' })
558
559
  result.observe(mu.external, { kind: 'external' })
560
+ // arrayBuffers 분리 노출(ADR-215, G5 M-5) — mongo driver 등 ArrayBuffer 상주분을 heap 과 구분해
561
+ // "RSS 만 보는" 관측 함정(burst 후 V8 페이지 미반환 = 정상 평형)을 운영자가 분해해 읽을 수 있게.
562
+ result.observe(mu.arrayBuffers ?? 0, { kind: 'array_buffers' })
563
+ })
564
+
565
+ // V8 힙 통계(ADR-215, G5 M-5) — heap_size_limit(OOM 한계)·total_available_size(여유)·physical 등
566
+ // process.memoryUsage 가 못 보여주는 V8 내부 수위. kind 는 고정 enum 이라 카디널리티 안전.
567
+ const V8_HEAP_KINDS = Object.freeze([
568
+ 'total_heap_size',
569
+ 'total_physical_size',
570
+ 'total_available_size',
571
+ 'used_heap_size',
572
+ 'heap_size_limit',
573
+ 'malloced_memory',
574
+ 'peak_malloced_memory',
575
+ 'external_memory',
576
+ ])
577
+ const v8Heap = meter.createObservableGauge('mega_v8_heap_bytes', {
578
+ description: `V8 힙 통계(바이트, v8.getHeapStatistics) — kind=${V8_HEAP_KINDS.join('|')} 라벨.`,
579
+ unit: 'By',
580
+ })
581
+ v8Heap.addCallback((result) => {
582
+ const hs = /** @type {Record<string, number>} */ (/** @type {unknown} */ (getHeapStatistics()))
583
+ for (const kind of V8_HEAP_KINDS) {
584
+ if (typeof hs[kind] === 'number') result.observe(hs[kind], { kind })
585
+ }
559
586
  })
560
587
 
561
588
  const uptime = meter.createObservableGauge('mega_process_uptime_seconds', {
@@ -62,12 +62,12 @@ const LIFECYCLE_EVENTS = /** @type {const} */ (['beforeBoot', 'afterBoot', 'befo
62
62
  * @property {string} [description] - `mega help` 가 병기하는 한 줄 설명(선택).
63
63
  */
64
64
 
65
- /** 빌트인 generator 13종 이름 — 플러그인 scaffold 가 점유 금지(빌트인이 우선이라 silent shadow 가 됨).
65
+ /** 빌트인 generator 이름 — 플러그인 scaffold 가 점유 금지(빌트인이 우선이라 silent shadow 가 됨).
66
66
  * cli/generators 의 `GENERATOR_KINDS` 와 동일해야 한다(레이어 역전 회피를 위해 미러 — 동기화는
67
67
  * mega-plugin 단위 테스트가 GENERATOR_KINDS 와 집합 비교로 강제한다). */
68
68
  export const RESERVED_GENERATOR_NAMES = new Set([
69
69
  'app', 'controller', 'channel', 'service', 'model', 'middleware', 'route',
70
- 'schedule', 'job', 'worker', 'locale', 'adapter', 'migration',
70
+ 'schedule', 'job', 'worker', 'locale', 'adapter', 'migration', 'adr',
71
71
  ])
72
72
 
73
73
  /**
@@ -16,7 +16,9 @@
16
16
  * 프로세스 메모리 격리), process=`child_process.fork`(완전 격리, 더 무거움). 둘 다 node 빌트인(의존성 0).
17
17
  * - **풀 정책** — `static poolSize`(디폴트 `os.cpus().length - 1`, 최소 1). 작업 큐 + 가용 워커에 디스패치.
18
18
  * - **crash 자동 재시작** — 워커가 예기치 않게 죽으면 in-flight task 를 `worker.crashed` 로 reject 하고
19
- * `static maxRestarts`(디폴트 5)까지 교체 워커를 띄운다(MegaJobWorker M-1 패턴 정합).
19
+ * `static maxRestarts`(디폴트 5)/`static restartWindowMs`(디폴트 60s) **슬라이딩 윈도우** 안에서만
20
+ * 교체 워커를 띄운다 — 윈도우 내 한도 초과 = crash-loop 으로 보고 포기(MegaCluster 정책 통일).
21
+ * 산발 crash(윈도우 밖)는 한도를 소모하지 않아 장수 프로세스의 풀이 영구 축소되지 않는다.
20
22
  * - **graceful shutdown** — `stop()`: 새 `run()` 거부 + 큐 대기분 `worker.stopped` reject + in-flight 완료
21
23
  * 대기(allSettled) → 워커 terminate. `MegaShutdown` 통합은 workers-manager 가 배선.
22
24
  *
@@ -57,6 +59,14 @@ const DEFAULT_POOL_SIZE = Math.max(1, os.cpus().length - 1)
57
59
  /** crash 시 풀 전체에서 허용하는 교체 워커 재시작 총량 디폴트. */
58
60
  const DEFAULT_MAX_RESTARTS = 5
59
61
 
62
+ /**
63
+ * crash 교체 판정 윈도우 기본값(ms) — 60초. `maxRestarts` 는 이 윈도우 **안의** 교체 횟수 상한이다
64
+ * (수명 누적이 아님). 누적 카운터면 몇 주 간격의 산발 crash 도 한도를 소모해 장수 프로세스의 풀이
65
+ * 영구 축소되다 `pool_exhausted` 로 죽는다 — crash-loop(즉시 연속 사망)만 막으면 되는 안전망이므로
66
+ * 시간 윈도우 판정이 맞다(MegaCluster 의 rapid-crash 슬라이딩 윈도우와 정책 통일).
67
+ */
68
+ const DEFAULT_RESTART_WINDOW_MS = 60_000
69
+
60
70
  /**
61
71
  * @typedef {object} WorkerHandle - 풀 안의 워커 1개 핸들.
62
72
  * @property {number} id - 핸들 식별자(로그/디버그).
@@ -94,7 +104,8 @@ export class MegaWorker extends EventEmitter {
94
104
  /** @type {boolean} */ #stopping = false
95
105
  /** @type {number} */ #nextTaskId = 1
96
106
  /** @type {number} */ #nextHandleId = 1
97
- /** @type {number} crash 교체 누적. */ #restarts = 0
107
+ /** @type {number[]} crash 교체 시각(epoch ms) 슬라이딩 윈도우 — restartWindowMs 밖은 판정 시 제거. */
108
+ #restartTimes = []
98
109
  /** @type {number} 진행 중인 교체 spawn 수(일시적 풀 0 상태에서 run 을 큐잉할지 판단). */ #pendingRespawns = 0
99
110
 
100
111
  /**
@@ -130,12 +141,18 @@ export class MegaWorker extends EventEmitter {
130
141
  return typeof p === 'number' ? p : DEFAULT_POOL_SIZE
131
142
  }
132
143
 
133
- /** @returns {number} crash 교체 허용 총량. */
144
+ /** @returns {number} 윈도우({@link MegaWorker#restartWindowMs}) 내 crash 교체 허용 횟수. */
134
145
  get maxRestarts() {
135
146
  const r = /** @type {any} */ (this.constructor).maxRestarts
136
147
  return Number.isInteger(r) && r >= 0 ? r : DEFAULT_MAX_RESTARTS
137
148
  }
138
149
 
150
+ /** @returns {number} crash 교체 판정 슬라이딩 윈도우(ms). `static restartWindowMs` 로 조정. */
151
+ get restartWindowMs() {
152
+ const w = /** @type {any} */ (this.constructor).restartWindowMs
153
+ return Number.isInteger(w) && w > 0 ? w : DEFAULT_RESTART_WINDOW_MS
154
+ }
155
+
139
156
  /** @returns {boolean} start() 후 stop() 전이면 true. */
140
157
  get isStarted() {
141
158
  return this.#started
@@ -511,8 +528,14 @@ export class MegaWorker extends EventEmitter {
511
528
  * @returns {void}
512
529
  */
513
530
  #scheduleRespawn() {
514
- if (this.#stopping || this.#restarts >= this.maxRestarts) return
515
- this.#restarts++
531
+ if (this.#stopping) return
532
+ // 슬라이딩 윈도우 판정 — 윈도우 밖 기록을 비우고 남은 횟수가 한도면 crash-loop 으로 보고 포기.
533
+ // (수명 누적이 아니라서 산발 crash 는 풀을 영구 축소시키지 않는다 — MegaCluster 정책 통일.)
534
+ const now = Date.now()
535
+ const windowStart = now - this.restartWindowMs
536
+ this.#restartTimes = this.#restartTimes.filter((t) => t >= windowStart)
537
+ if (this.#restartTimes.length >= this.maxRestarts) return
538
+ this.#restartTimes.push(now)
516
539
  this.#pendingRespawns++
517
540
  this.#spawn()
518
541
  .then((h) => {
package/src/lib/ws-hub.js CHANGED
@@ -103,7 +103,7 @@ export class MegaWsHub {
103
103
  this._wss = null
104
104
  /** heartbeat liveness 체크 interval (M3). @type {ReturnType<typeof setInterval> | null} */
105
105
  this._livenessTimer = null
106
- /** 등록된 bridge 연결. connId → { socket, lastSeen, protocolVersion }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number, protocolVersion?: number }>} */
106
+ /** 등록된 bridge 연결. connId → { socket, lastSeen, protocolVersion, presenceFanout }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number, protocolVersion?: number, presenceFanout?: boolean }>} */
107
107
  this._bridges = new Map()
108
108
  /** presence. sessionId → { bridgeConnId, userId, channels:Set, metadata }. @type {Map<string, { bridgeConnId: string, userId: string, channels: Set<string>, metadata: Object }>} */
109
109
  this._sessions = new Map()
@@ -111,6 +111,13 @@ export class MegaWsHub {
111
111
  this._channelSessions = new Map()
112
112
  /** userId → sessionId 집합 (DIRECT fan-out, ADR-035). @type {Map<string, Set<string>>} */
113
113
  this._userSessions = new Map()
114
+ /**
115
+ * 역인덱스: channel → (bridgeConnId → 그 bridge 의 채널 내 세션 수). BROADCAST 의 bridge 선정을
116
+ * 세션 수 무관 O(bridge 수)로 만든다 — 멤버 10k 채널에서 메시지당 수백 µs 가 µs 대로 떨어진다.
117
+ * `_addSession`/`_removeSession` 이 증분 유지(카운트 0 → 키 제거), 메모리는 채널×bridge 수준.
118
+ * @type {Map<string, Map<string, number>>}
119
+ */
120
+ this._channelBridges = new Map()
114
121
  }
115
122
 
116
123
  /** hub 식별자. */
@@ -170,6 +177,20 @@ export class MegaWsHub {
170
177
  let isRegistered = false
171
178
  this._log?.debug?.({ connId }, 'ws-hub connection (awaiting register)')
172
179
 
180
+ // 미등록 연결 register 타임아웃 — liveness(_checkLiveness)는 등록된 bridge 만 스캔하므로,
181
+ // REGISTER 를 안 보내는 연결(오설정 bridge·포트 스캐너·half-open)은 이 타이머가 없으면
182
+ // fd/메모리로 영구 잔존한다. heartbeatMs 안에 등록 못 하면 1008 로 닫는다(fail-closed).
183
+ const registerTimer = setTimeout(() => {
184
+ if (isRegistered) return
185
+ this._log?.warn?.({ connId, timeoutMs: this._heartbeatMs }, 'ws-hub register timeout — closing unregistered connection (1008)')
186
+ try {
187
+ socket.close(1008, 'register timeout')
188
+ } catch (err) {
189
+ this._log?.debug?.({ err, connId }, 'ws-hub register-timeout close failed (already closing)')
190
+ }
191
+ }, this._heartbeatMs)
192
+ registerTimer.unref?.()
193
+
173
194
  socket.on('message', (raw) => {
174
195
  const frame = Array.isArray(raw) ? Buffer.concat(raw).toString('utf8') : raw.toString('utf8')
175
196
  let msg
@@ -196,12 +217,14 @@ export class MegaWsHub {
196
217
  return
197
218
  }
198
219
  isRegistered = this._handleRegister(connId, socket, msg)
220
+ if (isRegistered) clearTimeout(registerTimer)
199
221
  return
200
222
  }
201
223
  this._route(connId, socket, msg)
202
224
  })
203
225
 
204
226
  socket.on('close', () => {
227
+ clearTimeout(registerTimer) // 미등록 타임아웃 정리(등록 전 절단·정상 종료 공통).
205
228
  if (isRegistered) this._handleBridgeGone(connId)
206
229
  this._log?.debug?.({ connId }, 'ws-hub connection closed')
207
230
  })
@@ -234,7 +257,10 @@ export class MegaWsHub {
234
257
  const protocolVersion = hasVersionRequest
235
258
  ? negotiateHubProtocolVersion(payload.protocolVersion)
236
259
  : HUB_PROTOCOL_VERSION
237
- this._bridges.set(connId, { socket, lastSeen: Date.now(), protocolVersion })
260
+ // presence fan-out 옵트인 — roster 가 redis 로 분리(ADR-177) bridge 는 JOIN/LEAVE/METADATA
261
+ // 를 no-op 으로 버리므로, `presence-fanout` capability 를 선언한 bridge 에만 보낸다(죽은 트래픽 제거).
262
+ const presenceFanout = Array.isArray(payload.capabilities) && payload.capabilities.includes('presence-fanout')
263
+ this._bridges.set(connId, { socket, lastSeen: Date.now(), protocolVersion, presenceFanout })
238
264
  this._log?.info?.({ connId, instanceId: payload.instanceId, hubId: this._hubId, protocolVersion }, 'ws-hub bridge registered')
239
265
  this._safeSend(socket, createHubMessage({
240
266
  type: T.REGISTER_OK,
@@ -262,12 +288,12 @@ export class MegaWsHub {
262
288
  switch (type) {
263
289
  case T.JOIN:
264
290
  this._addSession(connId, payload)
265
- // 클러스터 presence 공유 — 다른 모든 bridge 같은 JOIN fan-out (07 §2).
266
- this._fanOutToOthers(connId, msg)
291
+ // 클러스터 presence 공유 — `presence-fanout` 선언 bridge 에만 (07 §2 + ADR-177 죽은 트래픽 제거).
292
+ this._fanOutPresence(connId, msg)
267
293
  break
268
294
  case T.LEAVE: {
269
295
  const removed = this._removeSession(payload.sessionId)
270
- if (removed) this._fanOutToOthers(connId, msg)
296
+ if (removed) this._fanOutPresence(connId, msg)
271
297
  break
272
298
  }
273
299
  case T.BULK_LEAVE: {
@@ -289,7 +315,7 @@ export class MegaWsHub {
289
315
  const session = this._sessions.get(payload.sessionId)
290
316
  if (session) {
291
317
  session.metadata = payload.metadata
292
- this._fanOutToOthers(connId, msg) // presence 메타 동기화
318
+ this._fanOutPresence(connId, msg) // presence 메타 동기화 — 옵트인 bridge 만.
293
319
  }
294
320
  break
295
321
  }
@@ -360,6 +386,7 @@ export class MegaWsHub {
360
386
  this._channelSessions.set(ch, set)
361
387
  }
362
388
  set.add(entry.sessionId)
389
+ this._bumpChannelBridge(ch, connId, +1)
363
390
  }
364
391
  let uset = this._userSessions.get(entry.userId)
365
392
  if (!uset) {
@@ -369,6 +396,27 @@ export class MegaWsHub {
369
396
  uset.add(entry.sessionId)
370
397
  }
371
398
 
399
+ /**
400
+ * channel→bridge 역인덱스 증분 갱신. 카운트가 0 이 되면 키를 지워 인덱스가 stale 하지 않게 한다.
401
+ * @param {string} channel @param {string} connId @param {1|-1} delta
402
+ * @private
403
+ */
404
+ _bumpChannelBridge(channel, connId, delta) {
405
+ let counts = this._channelBridges.get(channel)
406
+ if (!counts) {
407
+ if (delta < 0) return // 제거인데 인덱스 없음 — 정합상 없을 일이지만 방어.
408
+ counts = new Map()
409
+ this._channelBridges.set(channel, counts)
410
+ }
411
+ const next = (counts.get(connId) ?? 0) + delta
412
+ if (next > 0) {
413
+ counts.set(connId, next)
414
+ } else {
415
+ counts.delete(connId)
416
+ if (counts.size === 0) this._channelBridges.delete(channel)
417
+ }
418
+ }
419
+
372
420
  /**
373
421
  * presence 에서 세션 제거. 빈 인덱스는 정리.
374
422
  * @param {string} sessionId
@@ -385,6 +433,7 @@ export class MegaWsHub {
385
433
  set.delete(sessionId)
386
434
  if (set.size === 0) this._channelSessions.delete(ch)
387
435
  }
436
+ this._bumpChannelBridge(ch, session.bridgeConnId, -1)
388
437
  }
389
438
  const uset = this._userSessions.get(session.userId)
390
439
  if (uset) {
@@ -415,7 +464,8 @@ export class MegaWsHub {
415
464
  }
416
465
 
417
466
  /**
418
- * origin 을 제외한 모든 등록 bridge 로 송신 (presence 공유용). envelope 는 1회만 직렬화(L5).
467
+ * origin 을 제외한 모든 등록 bridge 로 송신. envelope 는 1회만 직렬화(L5).
468
+ * BULK_LEAVE(bridge-gone 정리 통지)처럼 전 bridge 가 받아야 하는 통지에 쓴다.
419
469
  * @param {string} exceptConnId
420
470
  * @param {Object} envelope
421
471
  * @private
@@ -427,6 +477,25 @@ export class MegaWsHub {
427
477
  }
428
478
  }
429
479
 
480
+ /**
481
+ * presence(JOIN/LEAVE/METADATA) fan-out — `presence-fanout` capability 를 선언한 bridge 에만 송신.
482
+ * roster 가 redis 로 분리(ADR-177)된 뒤 표준 bridge 는 이 타입들을 no-op 으로 버리므로, 옵트인하지
483
+ * 않은 bridge 에는 보내지 않는다 — 세션 churn × bridge 수 만큼의 죽은 프레임을 제거한다.
484
+ * 구버전 bridge 호환: hub presence 를 실제로 쓰려는 bridge 는 capability 로 명시 선언한다.
485
+ * @param {string} exceptConnId
486
+ * @param {Object} envelope
487
+ * @private
488
+ */
489
+ _fanOutPresence(exceptConnId, envelope) {
490
+ /** @type {string | null} 첫 대상에서 1회 직렬화(L5) — 대상 0 이면 직렬화도 안 함. */
491
+ let data = null
492
+ for (const [connId, bridge] of this._bridges) {
493
+ if (connId === exceptConnId || !(/** @type {any} */ (bridge).presenceFanout)) continue
494
+ if (data === null) data = JSON.stringify(envelope)
495
+ this._sendSerialized(bridge.socket, data)
496
+ }
497
+ }
498
+
430
499
  /**
431
500
  * 한 채널의 세션을 가진 bridge 들로 fan-out (origin 제외, 중복 bridge 1회). 직렬화 1회(L5).
432
501
  *
@@ -441,12 +510,24 @@ export class MegaWsHub {
441
510
  * @private
442
511
  */
443
512
  _fanOutChannel(channel, exceptConnId, envelope, exceptSessionIds) {
513
+ const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
514
+ if (!except) {
515
+ // 일반 경로(대부분의 메시지): channel→bridge 역인덱스로 선정이 O(bridge 수) — 세션 수 무관.
516
+ const counts = this._channelBridges.get(channel)
517
+ if (!counts || counts.size === 0) return
518
+ const data = JSON.stringify(envelope)
519
+ for (const connId of counts.keys()) {
520
+ if (connId !== exceptConnId) this._sendTo(connId, data)
521
+ }
522
+ return
523
+ }
524
+ // exceptSessionIds 경로(드묾): "제외 세션만 가진 bridge 통째 스킵" 판정에 세션 단위 정보가
525
+ // 필요하므로 기존 멤버 순회를 유지한다.
444
526
  const sids = this._channelSessions.get(channel)
445
527
  if (!sids) return
446
- const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
447
528
  const targets = new Set()
448
529
  for (const sid of sids) {
449
- if (except && except.has(sid)) continue // 제외 세션은 bridge 선정에 기여하지 않음
530
+ if (except.has(sid)) continue // 제외 세션은 bridge 선정에 기여하지 않음
450
531
  const session = this._sessions.get(sid)
451
532
  if (session && session.bridgeConnId !== exceptConnId) targets.add(session.bridgeConnId)
452
533
  }
@@ -0,0 +1,23 @@
1
+ ---
2
+ number: {{number}}
3
+ title: {{title}}
4
+ date: {{date}}
5
+ status: accepted
6
+ supersedes: []
7
+ superseded_by: null
8
+ tags: []
9
+ ---
10
+
11
+ # ADR-{{number}}: {{title}}
12
+
13
+ ## Context
14
+
15
+ (결정이 필요했던 배경 — 문제·제약·관련 ADR 링크)
16
+
17
+ ## Decision
18
+
19
+ (무엇을 결정했는가 — 한 문장 요지 + 세부 사항)
20
+
21
+ ## Consequences
22
+
23
+ (트레이드오프·후속 작업·영향 파일·검증 결과)
@@ -65,6 +65,8 @@ export function normalizePool(pool: unknown, spec: Record<string, {
65
65
  key: string;
66
66
  divideBy?: number;
67
67
  } | null>, driver: string): Record<string, number>;
68
+ /** pool acquire 대기 한도 프레임워크 디폴트(ms) — 명시 0 으로 드라이버 무한 대기 옵트인(ADR-216). */
69
+ export const DEFAULT_ACQUIRE_TIMEOUT_MS: 10000;
68
70
  /**
69
71
  * pg 풀 매핑 — 값이 null 이면 미지원(throw), `{ key, divideBy? }` 면 키 이름 변경(+단위 변환).
70
72
  * @type {Record<string, { key: string, divideBy?: number } | null>}
@@ -4,6 +4,8 @@
4
4
  * @property {string} [basePath] - 캐시 파일 저장 디렉토리 (필수).
5
5
  * @property {string} [dir] - `basePath` 의 별칭 (ADR-082 정합, 하위 호환).
6
6
  * @property {{ serializer?: 'json' | 'raw', extension?: string }} [options]
7
+ * @property {string} [namespace] - 캐시 키 자동 prefix `mega:cache:<namespace>:` (ADR-064/213, 베이스 처리).
8
+ * @property {number} [defaultTtlSec] - `set` ttl 미지정 시 디폴트(초). 0 = 무한 옵트인. (ADR-216, 베이스 처리)
7
9
  */
8
10
  /**
9
11
  * @typedef {object} CacheEnvelope - 디스크에 저장되는 단일 파일 포맷.
@@ -14,7 +16,8 @@
14
16
  */
15
17
  export class MegaFileAdapter extends MegaCacheAdapter {
16
18
  /**
17
- * @param {FileConfig} [config] - services.caches.<key> 설정.
19
+ * @param {FileConfig} [config] - services.caches.<key> 설정. 베이스(MegaCacheAdapter)의
20
+ * `namespace`(ADR-064 자동 prefix)/`defaultTtlSec`(ADR-216) 도 여기서 받는다.
18
21
  * @throws {MegaValidationError} `adapter.basepath_required` - basePath/dir 누락.
19
22
  * @throws {MegaValidationError} `adapter.invalid_option` - 옵션 타입/미지원 키 오류.
20
23
  */
@@ -69,6 +72,14 @@ export type FileConfig = {
69
72
  serializer?: "json" | "raw";
70
73
  extension?: string;
71
74
  };
75
+ /**
76
+ * - 캐시 키 자동 prefix `mega:cache:<namespace>:` (ADR-064/213, 베이스 처리).
77
+ */
78
+ namespace?: string;
79
+ /**
80
+ * - `set` ttl 미지정 시 디폴트(초). 0 = 무한 옵트인. (ADR-216, 베이스 처리)
81
+ */
82
+ defaultTtlSec?: number;
72
83
  };
73
84
  /**
74
85
  * - 디스크에 저장되는 단일 파일 포맷.
@@ -41,13 +41,15 @@ export class MegaFileSessionAdapter extends MegaSessionAdapter {
41
41
  error?: string;
42
42
  }>;
43
43
  /**
44
- * 누적 통계 + file 세션 특화.
45
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, basePath: string, ttlMs: number }}
44
+ * 누적 통계 + file 세션 특화. `cleanupIntervalMs` 노출(0=내부 타이머 off) — 만료 스캔 주기의
45
+ * 적용 여부를 운영/테스트가 확인하는 단일 창구(ADR-215).
46
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, basePath: string, ttlMs: number, cleanupIntervalMs: number }}
46
47
  */
47
48
  getStats(): ReturnType<import("./mega-adapter.js").MegaAdapter["getStats"]> & {
48
49
  driver: string;
49
50
  basePath: string;
50
51
  ttlMs: number;
52
+ cleanupIntervalMs: number;
51
53
  };
52
54
  #private;
53
55
  }
@@ -27,15 +27,17 @@ export class MegaMariaAdapter extends MegaDbAdapter {
27
27
  error?: string;
28
28
  }>;
29
29
  /**
30
- * 누적 통계 + 풀 통계(total/idle/active/queue). 연결 전이면 통계는 0.
31
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, idle: number, active: number, queue: number } }}
30
+ * 누적 통계 + 풀 통계 — 공통 코어 키 `{ total, active, idle, waiting }` 4 driver 동일
31
+ * 형태(ADR-216 G2 H-2). `queue` 기존 소비자 하위 호환 별칭(= waiting). 연결 전이면 0.
32
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, active: number, idle: number, waiting: number, queue: number } }}
32
33
  */
33
34
  getStats(): ReturnType<import("./mega-adapter.js").MegaAdapter["getStats"]> & {
34
35
  driver: string;
35
36
  pool: {
36
37
  total: number;
37
- idle: number;
38
38
  active: number;
39
+ idle: number;
40
+ waiting: number;
39
41
  queue: number;
40
42
  };
41
43
  };
@@ -1,4 +1,29 @@
1
1
  export class MegaCacheAdapter extends MegaAdapter {
2
+ /**
3
+ * @param {{ namespace?: string, defaultTtlSec?: number } & Record<string, any>} [config]
4
+ * @throws {MegaValidationError} `adapter.invalid_option` - namespace/defaultTtlSec 형식 오류.
5
+ */
6
+ constructor(config?: {
7
+ namespace?: string;
8
+ defaultTtlSec?: number;
9
+ } & Record<string, any>);
10
+ /**
11
+ * 네임스페이스 적용 키 — 구체 어댑터의 get/set/del/has 가 저장소 접근 직전에 경유한다(ADR-064).
12
+ * @protected
13
+ * @param {string} key
14
+ * @returns {string}
15
+ */
16
+ protected _cacheKey(key: string): string;
17
+ /**
18
+ * `set` 의 유효 TTL 해석 — 명시 ttl 우선, 없으면 defaultTtlSec. 둘 다 없으면 무한 저장이며
19
+ * `defaultTtlSec: 0` 옵트인이 아닌 한 인스턴스당 1회 경고를 낸다(키 무한 증가 풋건 표면화 —
20
+ * 동작은 기존과 동일, ADR-216).
21
+ * @protected
22
+ * @param {number} [ttl] - 호출자가 명시한 ttl(초).
23
+ * @param {string} [key] - 경고 메시지용 예시 키.
24
+ * @returns {number | undefined} 적용할 ttl(초) — undefined 면 무한.
25
+ */
26
+ protected _resolveTtl(ttl?: number, key?: string): number | undefined;
2
27
  /**
3
28
  * @param {string} _key
4
29
  * @returns {Promise<any>} 값 — 없으면 `null` (throw X).
@@ -7,7 +32,7 @@ export class MegaCacheAdapter extends MegaAdapter {
7
32
  /**
8
33
  * @param {string} _key
9
34
  * @param {any} _value
10
- * @param {{ ttl?: number }} [_opts] - `ttl` 미지정 시 무한 (초 단위).
35
+ * @param {{ ttl?: number }} [_opts] - `ttl` 미지정 시 defaultTtlSec, 그것도 없으면 무한(초 단위, ADR-216).
11
36
  * @returns {Promise<void>}
12
37
  */
13
38
  set(_key: string, _value: any, _opts?: {
@@ -43,5 +68,6 @@ export class MegaCacheAdapter extends MegaAdapter {
43
68
  allowZero?: boolean;
44
69
  allowInfinity?: boolean;
45
70
  }): void;
71
+ #private;
46
72
  }
47
73
  import { MegaAdapter } from './mega-adapter.js';
@@ -4,7 +4,10 @@ export class MegaDbAdapter extends MegaAdapter {
4
4
  * driver 별 구현 (postgres `BEGIN/COMMIT/ROLLBACK`, MongoDB `session.withTransaction`).
5
5
  * nested 호출은 driver 별 (postgres SAVEPOINT, MongoDB throw `adapter.nested_transaction_unsupported`).
6
6
  *
7
- * `opts.isolation`(ADR-190) — SQL 격리수준 옵트인. 미지정이면 driver 디폴트. driver 별 지원:
7
+ * `opts.isolation`(ADR-190) — SQL 격리수준 옵트인. **미지정이면 driver 디폴트가 적용되며
8
+ * 디폴트는 driver 마다 다르다**(ADR-216 G2 M-2): postgres = READ COMMITTED,
9
+ * mariadb(InnoDB) = REPEATABLE READ — 같은 `withTransaction(fn)` 코드가 driver 에 따라 다른
10
+ * 동시성 의미를 가진다. 이식성 있는 동시성 가정이 필요하면 isolation 을 명시할 것. driver 별 지원:
8
11
  * postgres/maria 는 top-level 트랜잭션에 `SET TRANSACTION ISOLATION LEVEL` 로 반영(nested 엔 불가 —
9
12
  * `adapter.nested_isolation_unsupported`), sqlite 는 항상 SERIALIZABLE 동작이라 'serializable' 만 수용,
10
13
  * mongodb 는 SQL 격리수준 개념이 없어 지정 시 `adapter.invalid_option`.
@@ -9,6 +9,7 @@
9
9
  * @typedef {object} PoolCounters
10
10
  * @property {number} created @property {number} closed
11
11
  * @property {number} checkedOut @property {number} checkedIn
12
+ * @property {number} checkOutStarted @property {number} checkOutFailed
12
13
  */
13
14
  export class MegaMongoAdapter extends MegaDbAdapter {
14
15
  /**
@@ -41,13 +42,21 @@ export class MegaMongoAdapter extends MegaDbAdapter {
41
42
  error?: string;
42
43
  }>;
43
44
  /**
44
- * 누적 통계 + mongo 특화(driver/dbName + CMAP 풀 카운터). 연결 전이면 카운터는 0.
45
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, dbName: string, pool: { created: number, closed: number, checkedOut: number, checkedIn: number, open: number, inUse: number } }}
45
+ * 누적 통계 + mongo 특화(driver/dbName + CMAP 풀 카운터). 공통 코어
46
+ * `{ total, active, idle, waiting }` 4 driver 동일 형태(ADR-216 G2 H-2 CMAP 누적
47
+ * 카운터에서 파생: total=created-closed, active=checkedOut-checkedIn,
48
+ * waiting=checkOutStarted-(checkedOut+checkOutFailed)). 누적 원본도 유지(하위 호환).
49
+ * 연결 전이면 전부 0.
50
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, dbName: string, pool: { total: number, active: number, idle: number, waiting: number, created: number, closed: number, checkedOut: number, checkedIn: number, open: number, inUse: number } }}
46
51
  */
47
52
  getStats(): ReturnType<import("./mega-adapter.js").MegaAdapter["getStats"]> & {
48
53
  driver: string;
49
54
  dbName: string;
50
55
  pool: {
56
+ total: number;
57
+ active: number;
58
+ idle: number;
59
+ waiting: number;
51
60
  created: number;
52
61
  closed: number;
53
62
  checkedOut: number;
@@ -135,5 +144,7 @@ export type PoolCounters = {
135
144
  closed: number;
136
145
  checkedOut: number;
137
146
  checkedIn: number;
147
+ checkOutStarted: number;
148
+ checkOutFailed: number;
138
149
  };
139
150
  import { MegaDbAdapter } from './mega-db-adapter.js';
@@ -42,13 +42,15 @@ export class MegaPostgresAdapter extends MegaDbAdapter {
42
42
  error?: string;
43
43
  }>;
44
44
  /**
45
- * 누적 통계 + 풀 통계(total/idle/waiting). 연결 전이면 통계는 0.
46
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, idle: number, waiting: number } }}
45
+ * 누적 통계 + 풀 통계 — 공통 코어 키 `{ total, active, idle, waiting }` 4 driver 동일
46
+ * 형태(ADR-216 G2 H-2: 운영 대시보드가 driver 무관 코드로 "풀 포화" 묻게). 연결 전이면 0.
47
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, active: number, idle: number, waiting: number } }}
47
48
  */
48
49
  getStats(): ReturnType<import("./mega-adapter.js").MegaAdapter["getStats"]> & {
49
50
  driver: string;
50
51
  pool: {
51
52
  total: number;
53
+ active: number;
52
54
  idle: number;
53
55
  waiting: number;
54
56
  };
@@ -62,6 +62,14 @@ export type RedisConfig = {
62
62
  * - 미지원 — 지정 시 `adapter.invalid_option` throw (Redis 는 풀 모델 아님, ADR-110).
63
63
  */
64
64
  pool?: any;
65
+ /**
66
+ * - 캐시 키 자동 prefix `mega:cache:<namespace>:` (ADR-064/213 — 멀티앱 충돌 차단, 옵트인).
67
+ */
68
+ namespace?: string;
69
+ /**
70
+ * - `set` 의 ttl 미지정 시 적용할 디폴트(초). 0 = 무한 저장 옵트인(경고 억제). (ADR-216)
71
+ */
72
+ defaultTtlSec?: number;
65
73
  /**
66
74
  * - ioredis passthrough (keyPrefix, commandTimeout, tls, retryStrategy, keepAlive, …).
67
75
  */
@@ -37,8 +37,8 @@ export class MegaSqliteAdapter extends MegaDbAdapter {
37
37
  error?: string;
38
38
  }>;
39
39
  /**
40
- * 누적 통계 + sqlite 특화 필드.
41
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, filename: string, inMemory: boolean, readonly: boolean, journalMode: string | undefined }}
40
+ * 누적 통계 + sqlite 특화 필드 + 공통 풀 코어 키(합성 — 단일 연결, ADR-216).
41
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, filename: string, inMemory: boolean, readonly: boolean, journalMode: string | undefined, pool: { total: number, active: number, idle: number, waiting: number } }}
42
42
  */
43
43
  getStats(): ReturnType<import("./mega-adapter.js").MegaAdapter["getStats"]> & {
44
44
  driver: string;
@@ -46,6 +46,12 @@ export class MegaSqliteAdapter extends MegaDbAdapter {
46
46
  inMemory: boolean;
47
47
  readonly: boolean;
48
48
  journalMode: string | undefined;
49
+ pool: {
50
+ total: number;
51
+ active: number;
52
+ idle: number;
53
+ waiting: number;
54
+ };
49
55
  };
50
56
  /**
51
57
  * 명시적 트랜잭션 경계 (ADR-010, ADR-105). manual `BEGIN/COMMIT/ROLLBACK` 으로
@@ -13,6 +13,16 @@
13
13
  * @returns {Artifact[]}
14
14
  */
15
15
  export function planArtifacts(kind: string, rawName: string, opts: Record<string, any>, projectRoot: string): Artifact[];
16
+ /**
17
+ * 다음 ADR 번호를 해석한다 — `docs/adr/NNNN-*.md` 파일명 + 레거시 `docs/09` 헤딩(`### ADR-N:`) +
18
+ * 호출측이 모은 추가 번호(예: `git ls-tree origin/main` 의 원격 파일 — 병렬 task 충돌 회피)의
19
+ * 최댓값 + 1. 아무 ADR 도 없으면 1.
20
+ *
21
+ * @param {string} projectRoot
22
+ * @param {number[]} [extraNumbers] - 로컬 밖에서 관측한 번호(원격 스캔 등).
23
+ * @returns {number}
24
+ */
25
+ export function nextAdrNumber(projectRoot: string, extraNumbers?: number[]): number;
16
26
  /**
17
27
  * 계획된 artifact 를 디스크에 쓴다. 이미 있으면 건너뛴다(force 면 덮어씀).
18
28
  * @param {Artifact[]} artifacts
@@ -83,7 +93,7 @@ export function generate(kind: string, rawName: string, opts?: object, projectRo
83
93
  skipped: string[];
84
94
  };
85
95
  /** 지원 generator 종류(roadmap §337 — 13종). */
86
- export const GENERATOR_KINDS: readonly ["app", "controller", "channel", "service", "model", "middleware", "route", "schedule", "job", "worker", "locale", "adapter", "migration"];
96
+ export const GENERATOR_KINDS: readonly ["app", "controller", "channel", "service", "model", "middleware", "route", "schedule", "job", "worker", "locale", "adapter", "migration", "adr"];
87
97
  /**
88
98
  * 플러그인 scaffold manifest 의 토큰 계약(ADR-199) — `files[].path`/`files[].template` 의 `{{token}}`
89
99
  * 에 쓸 수 있는 이름과 의미. 빌트인 generator 의 base 토큰과 동일 집합이라 템플릿 작성 관례가 하나다.