mega-framework 0.1.7 → 0.1.8

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 (76) 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/locales/server/en.json +12 -1
  6. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  7. package/sample/crud/mega.config.js +7 -0
  8. package/sample/crud/package.json +2 -2
  9. package/sample/crud/scripts/start-ws-hub.sh +18 -4
  10. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/src/adapters/adapter-options.js +14 -3
  12. package/src/adapters/file-adapter.js +9 -5
  13. package/src/adapters/file-session-adapter.js +4 -3
  14. package/src/adapters/maria-adapter.js +7 -4
  15. package/src/adapters/mega-cache-adapter.js +83 -6
  16. package/src/adapters/mega-db-adapter.js +4 -1
  17. package/src/adapters/mongo-adapter.js +21 -7
  18. package/src/adapters/postgres-adapter.js +8 -4
  19. package/src/adapters/redis-adapter.js +7 -3
  20. package/src/adapters/sqlite-adapter.js +6 -2
  21. package/src/cli/commands/console-cmd.js +3 -1
  22. package/src/cli/commands/scaffold.js +38 -2
  23. package/src/cli/generators/index.js +58 -1
  24. package/src/cli/index.js +88 -59
  25. package/src/cli/watch.js +188 -0
  26. package/src/core/ajv-mapper.js +3 -1
  27. package/src/core/ctx-builder.js +59 -1
  28. package/src/core/envelope.js +9 -2
  29. package/src/core/hub-link.js +24 -14
  30. package/src/core/index.js +1 -1
  31. package/src/core/mega-app.js +55 -45
  32. package/src/core/pipeline.js +8 -6
  33. package/src/core/scope-registry.js +1 -0
  34. package/src/core/security.js +3 -3
  35. package/src/core/session-store.js +14 -1
  36. package/src/core/ws-presence.js +17 -5
  37. package/src/core/ws-roster.js +49 -10
  38. package/src/core/ws-upgrade.js +105 -0
  39. package/src/lib/mega-circuit-breaker.js +5 -3
  40. package/src/lib/mega-health.js +10 -0
  41. package/src/lib/mega-job-queue.js +53 -13
  42. package/src/lib/mega-job.js +8 -1
  43. package/src/lib/mega-metrics.js +28 -1
  44. package/src/lib/mega-plugin.js +2 -2
  45. package/src/lib/mega-worker.js +28 -5
  46. package/src/lib/ws-hub.js +90 -9
  47. package/templates/adr/code.tpl +23 -0
  48. package/types/adapters/adapter-options.d.ts +2 -0
  49. package/types/adapters/file-adapter.d.ts +12 -1
  50. package/types/adapters/file-session-adapter.d.ts +4 -2
  51. package/types/adapters/maria-adapter.d.ts +5 -3
  52. package/types/adapters/mega-cache-adapter.d.ts +27 -1
  53. package/types/adapters/mega-db-adapter.d.ts +4 -1
  54. package/types/adapters/mongo-adapter.d.ts +13 -2
  55. package/types/adapters/postgres-adapter.d.ts +4 -2
  56. package/types/adapters/redis-adapter.d.ts +8 -0
  57. package/types/adapters/sqlite-adapter.d.ts +8 -2
  58. package/types/cli/generators/index.d.ts +11 -1
  59. package/types/cli/index.d.ts +12 -27
  60. package/types/cli/watch.d.ts +59 -0
  61. package/types/core/ctx-builder.d.ts +23 -0
  62. package/types/core/hub-link.d.ts +3 -1
  63. package/types/core/index.d.ts +1 -1
  64. package/types/core/mega-app.d.ts +1 -1
  65. package/types/core/pipeline.d.ts +2 -1
  66. package/types/core/security.d.ts +3 -3
  67. package/types/core/session-store.d.ts +7 -0
  68. package/types/core/ws-roster.d.ts +13 -1
  69. package/types/core/ws-upgrade.d.ts +29 -0
  70. package/types/lib/mega-circuit-breaker.d.ts +4 -2
  71. package/types/lib/mega-health.d.ts +7 -0
  72. package/types/lib/mega-job-queue.d.ts +16 -4
  73. package/types/lib/mega-job.d.ts +8 -1
  74. package/types/lib/mega-plugin.d.ts +1 -1
  75. package/types/lib/mega-worker.d.ts +3 -1
  76. package/types/lib/ws-hub.d.ts +27 -2
package/README.md CHANGED
@@ -39,6 +39,15 @@ npm test # 전체 테스트(실 인프라). .env 자동 로드(ADR-
39
39
  npm run infra:down # 인프라 종료
40
40
  ```
41
41
 
42
+ > **워크스페이스 안내 (ADR-197)** — `sample/crud`·`sample/simple` 은 npm workspaces 라서 모든 의존성이
43
+ > **루트 `node_modules` 로 hoist** 된다. `sample/crud/node_modules` 가 안 생기는 것이 **정상**이며
44
+ > (sample 안에서 `npm install` 을 실행해도 루트에 설치된다), `mega-framework` 는
45
+ > `node_modules/mega-framework → ..` self-link 로 해석된다. 확인: `npm ls --workspace sample-crud`.
46
+ >
47
+ > **yarn 미지원** — yarn v1 은 workspaces 에 루트 `"private": true` 를 요구하는데 본 패키지는 npm 배포
48
+ > 대상(`private: false`)이라 `yarn install` 이 `Workspaces can only be enabled in private projects` 로
49
+ > 실패한다. **npm 만 사용**한다(lockfile 정본 = `package-lock.json` 1벌).
50
+
42
51
  프로젝트 루트에 `mega.config.js`(글로벌) + 앱별 `apps/<name>/app.config.js` 를 둔다.
43
52
 
44
53
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -69,7 +69,7 @@
69
69
  "test": "vitest run",
70
70
  "test:watch": "vitest",
71
71
  "test:coverage": "vitest run --coverage",
72
- "lint": "eslint src test",
72
+ "lint": "eslint src test scripts",
73
73
  "typecheck": "tsc -p jsconfig.json --noEmit",
74
74
  "build:types": "rm -rf types && tsc -p tsconfig.build.json",
75
75
  "format": "prettier -w src test packages",
@@ -79,8 +79,8 @@
79
79
  "infra:reset": "docker compose down -v && docker compose up -d --wait",
80
80
  "test:integration": "npm run infra:up && vitest run test/integration && npm run infra:down",
81
81
  "test:unit": "vitest run test/unit",
82
+ "adr:index": "node scripts/build-adr-index.js",
82
83
  "prepublishOnly": "npm run build:types && npm run lint && npm run typecheck && npm run test:unit",
83
- "prepare": "npm run build:types",
84
84
  "release": "bash scripts/publish.sh"
85
85
  },
86
86
  "devDependencies": {
package/sample/crud/.env CHANGED
@@ -20,6 +20,15 @@
20
20
  # NODE_ENV=development
21
21
 
22
22
 
23
+ # ── dev watch ignore (mega.config.js > watch, ADR-220) ───────────────────────
24
+ # `mega start --watch` 의 ignore 목록(콤마 구분 glob) — **여기가 정본**(숨은 코드 디폴트 없음).
25
+ # "런타임이 스스로 쓰거나 재시작 없이 반영되는" 영역을 나열한다. 폴더명을 바꿨으면 이 줄만 고친다.
26
+ # locales: dev 의 i18n saveMissing 자동 기입(안 빼면 재시작 무한 루프) / views: EJS 는 dev 에서
27
+ # 매 요청 재컴파일 / public: 정적 자산 / uploads·var: 사용자 업로드 등 런타임 데이터 /
28
+ # .mega: 마이그레이션 journal / .env*: 시크릿 변경은 수동 재시작.
29
+ # 비우면 모든 변경이 재시작 대상이다(기동 시 경고 1줄).
30
+ WATCH_IGNORE=**/locales/**,**/views/**,**/public/**,**/uploads/**,var/**,logs/**,tmp/**,.mega/**,**/node_modules/**,**/.git/**,**/*.test.js,**/*.spec.js,.env,.env.*,**/templates/**,data/**
31
+
23
32
  # ── 서버 (mega.config.js > server) ───────────────────────────────────────────
24
33
  # HTTP listen 포트 — server.port. CLI `--port N` 이 우선(ADR-146).
25
34
  PORT=3000
@@ -20,6 +20,15 @@
20
20
  # NODE_ENV=development
21
21
 
22
22
 
23
+ # ── dev watch ignore (mega.config.js > watch, ADR-220) ───────────────────────
24
+ # `mega start --watch` 의 ignore 목록(콤마 구분 glob) — **여기가 정본**(숨은 코드 디폴트 없음).
25
+ # "런타임이 스스로 쓰거나 재시작 없이 반영되는" 영역을 나열한다. 폴더명을 바꿨으면 이 줄만 고친다.
26
+ # locales: dev 의 i18n saveMissing 자동 기입(안 빼면 재시작 무한 루프) / views: EJS 는 dev 에서
27
+ # 매 요청 재컴파일 / public: 정적 자산 / uploads·var: 사용자 업로드 등 런타임 데이터 /
28
+ # .mega: 마이그레이션 journal / .env*: 시크릿 변경은 수동 재시작.
29
+ # 비우면 모든 변경이 재시작 대상이다(기동 시 경고 1줄).
30
+ WATCH_IGNORE=**/locales/**,**/views/**,**/public/**,**/uploads/**,var/**,logs/**,tmp/**,.mega/**,**/node_modules/**,**/.git/**,**/*.test.js,**/*.spec.js,.env,.env.*
31
+
23
32
  # ── 서버 (mega.config.js > server) ───────────────────────────────────────────
24
33
  # HTTP listen 포트 — server.port. CLI `--port N` 이 우선(ADR-146).
25
34
  PORT=3000
@@ -313,5 +313,16 @@
313
313
  "csrf": {
314
314
  "invalid_token": "Invalid csrf token",
315
315
  "missing_secret": "Missing csrf secret"
316
- }
316
+ },
317
+ "field_username": "아이디",
318
+ "field_username_ph": "예: hong123",
319
+ "field_username_required": "아이디를 입력하세요.",
320
+ "field_nickname": "별명",
321
+ "field_nickname_ph": "예: 길동이",
322
+ "field_nickname_required": "별명을 입력하세요.",
323
+ "optional": "선택",
324
+ "field_phone": "전화번호",
325
+ "field_phone_ph": "예: 010-1234-5678",
326
+ "col_username": "아이디",
327
+ "col_nickname": "별명"
317
328
  }
@@ -313,5 +313,16 @@
313
313
  },
314
314
  "guide": {
315
315
  "not_found": "가이드를 찾을 수 없습니다."
316
- }
316
+ },
317
+ "field_username": "아이디",
318
+ "field_username_ph": "예: hong123",
319
+ "field_username_required": "아이디를 입력하세요.",
320
+ "field_nickname": "별명",
321
+ "field_nickname_ph": "예: 길동이",
322
+ "field_nickname_required": "별명을 입력하세요.",
323
+ "optional": "선택",
324
+ "field_phone": "전화번호",
325
+ "field_phone_ph": "예: 010-1234-5678",
326
+ "col_username": "아이디",
327
+ "col_nickname": "별명"
317
328
  }
@@ -175,4 +175,11 @@ export default {
175
175
  // OpenTelemetry 분산 트레이싱 — **config 블록이 아니라 .env 의 MEGA_OTEL_\*** 로 설정한다
176
176
  // (MegaTracing.fromEnv, boot.js). MEGA_OTEL_ENABLED='true' + MEGA_OTEL_SERVICE_NAME 필수.
177
177
  // (`tracing` 키는 스키마엔 있으나 현재 부팅이 소비하지 않음 — 죽은 설정 회피 위해 블록을 두지 않음.)
178
+
179
+ // dev watch(`mega start --watch`) ignore — .env 의 WATCH_IGNORE(콤마 구분 glob)가 정본이다
180
+ // (ADR-220, 숨은 코드 디폴트 없음). 폴더명을 바꾼 프로젝트는 .env 의 그 줄만 고치면 된다.
181
+ // 미설정이면 ignore 0 — 모든 변경이 재시작 대상(기동 시 경고 1줄).
182
+ watch: {
183
+ ignore: (process.env.WATCH_IGNORE ?? '').split(',').map((p) => p.trim()).filter(Boolean),
184
+ },
178
185
  }
@@ -7,7 +7,7 @@
7
7
  "node": ">=20"
8
8
  },
9
9
  "scripts": {
10
- "dev": "mega start --watch",
10
+ "dev": "NODE_ENV=development mega start --watch",
11
11
  "start": "NODE_ENV=production mega start",
12
12
  "migrate": "mega migrate",
13
13
  "migrate:down": "mega migrate:down",
@@ -25,4 +25,4 @@
25
25
  "concurrently": "^9.0.0",
26
26
  "vitest": "^4.1.8"
27
27
  }
28
- }
28
+ }
@@ -3,7 +3,7 @@
3
3
  # sample/crud — WS Hub 서버 기동 스크립트 (ADR-032/176)
4
4
  #
5
5
  # 별도 hub 프로세스(`mega-ws-hub` 바이너리, mega-framework bin)를 localhost:3100 에 띄운다.
6
- # 앱(`yarn dev` = `mega start`)이 app.config 의 `bridgeHub` 로 이 허브에 **자동 연결**한다(ADR-176).
6
+ # 앱(`npm run dev` = `mega start`)이 app.config 의 `bridgeHub` 로 이 허브에 **자동 연결**한다(ADR-176).
7
7
  #
8
8
  # ⚠️ `mega-ws-hub` 는 `mega start` 와 달리 `.env` 를 자동 로드하지 않는다(직접 process.env 만 읽음,
9
9
  # src/lib/ws-hub.js). 그래서 Node 20.6+ 의 `--env-file` 로 .env 를 주입한다.
@@ -22,15 +22,29 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
22
22
  cd "$ROOT"
23
23
 
24
24
  ENV_FILE=".env"
25
- BIN="node_modules/mega-framework/bin/mega-ws-hub.js"
25
+
26
+ # mega-ws-hub 바이너리 탐색 — 두 설치 형상을 모두 지원한다:
27
+ # ① 독립 프로젝트(mega new 스캐폴드): 자기 node_modules 에 설치됨.
28
+ # ② 모노레포 workspaces(ADR-197): 의존성이 레포 루트로 hoist 되어 sample/crud 에는 node_modules 가
29
+ # 없다(정상). 루트의 mega-framework self-link 를 따라간다.
30
+ BIN=""
31
+ for candidate in \
32
+ "node_modules/mega-framework/bin/mega-ws-hub.js" \
33
+ "../../node_modules/mega-framework/bin/mega-ws-hub.js"; do
34
+ if [ -f "$candidate" ]; then
35
+ BIN="$candidate"
36
+ break
37
+ fi
38
+ done
26
39
 
27
40
  # 사전 점검 — 누락 시 silent 진행 금지, 이유를 명확히 알린다(P4/P7).
28
41
  [ -f "$ENV_FILE" ] || {
29
42
  echo "✗ $ROOT/$ENV_FILE 가 없습니다 — MEGA_WSHUB_TOKENS 등 허브 설정이 필요합니다." >&2
30
43
  exit 1
31
44
  }
32
- [ -f "$BIN" ] || {
33
- echo "✗ $BIN 없음 먼저 'yarn install --ignore-engines'(또는 npm install) 로 mega-framework 를 설치/동기화하세요." >&2
45
+ [ -n "$BIN" ] || {
46
+ echo "✗ mega-ws-hub.js 찾지 못했습니다(로컬/루트 node_modules 모두) 'npm install' 로 mega-framework 를 설치하세요." >&2
47
+ echo " (모노레포에서는 레포 루트에서 실행해야 합니다. yarn 은 미지원 — npm 사용.)" >&2
34
48
  exit 1
35
49
  }
36
50
 
@@ -0,0 +1 @@
1
+ {"version":"4.1.8","results":[[":test/apps/main/index.test.js",{"duration":1.6905000000000001,"failed":false}]]}
@@ -165,6 +165,9 @@ export function resolveConnection(config, { driver, dbKey = 'database', dbConfli
165
165
  return out
166
166
  }
167
167
 
168
+ /** pool acquire 대기 한도 프레임워크 디폴트(ms) — 명시 0 으로 드라이버 무한 대기 옵트인(ADR-216). */
169
+ export const DEFAULT_ACQUIRE_TIMEOUT_MS = 10_000
170
+
168
171
  /**
169
172
  * pg 풀 매핑 — 값이 null 이면 미지원(throw), `{ key, divideBy? }` 면 키 이름 변경(+단위 변환).
170
173
  * @type {Record<string, { key: string, divideBy?: number } | null>}
@@ -216,11 +219,11 @@ export const MONGO_POOL_SPEC = {
216
219
  * @returns {Record<string, number>} 드라이버 풀 옵션 객체(빈 객체 가능).
217
220
  */
218
221
  export function normalizePool(pool, spec, driver) {
219
- if (pool === undefined) return {}
220
- assertPlainObject('pool', pool, { driver })
221
222
  /** @type {Record<string, number>} */
222
223
  const out = {}
223
- for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (pool))) {
224
+ if (pool !== undefined) assertPlainObject('pool', pool, { driver })
225
+ const entries = pool === undefined ? [] : Object.entries(/** @type {Record<string, unknown>} */ (pool))
226
+ for (const [key, value] of entries) {
224
227
  if (value === undefined) continue
225
228
  const map = spec[key]
226
229
  if (map === undefined) {
@@ -234,5 +237,13 @@ export function normalizePool(pool, spec, driver) {
234
237
  const num = /** @type {number} */ (value)
235
238
  out[map.key] = map.divideBy ? Math.floor(num / map.divideBy) : num
236
239
  }
240
+ // 프레임워크 디폴트(ADR-216 G2 H-1): acquire 무한 대기(pg connectionTimeoutMillis=0 ·
241
+ // mongo waitQueueTimeoutMS=0 드라이버 디폴트)는 연결 leak 1건을 전체 서비스 행으로 키운다 —
242
+ // 미지정 시 10s 를 기본 적용한다(maria 드라이버 디폴트 10s 와 정렬). 명시 `acquireTimeoutMs: 0`
243
+ // 은 드라이버 무한 대기 의미 그대로 통과(무한 옵트인).
244
+ const acquireMap = spec.acquireTimeoutMs
245
+ if (acquireMap !== null && acquireMap !== undefined && out[acquireMap.key] === undefined) {
246
+ out[acquireMap.key] = DEFAULT_ACQUIRE_TIMEOUT_MS
247
+ }
237
248
  return out
238
249
  }
@@ -75,6 +75,8 @@ const KNOWN_OPTIONS = new Set(['serializer', 'extension'])
75
75
  * @property {string} [basePath] - 캐시 파일 저장 디렉토리 (필수).
76
76
  * @property {string} [dir] - `basePath` 의 별칭 (ADR-082 정합, 하위 호환).
77
77
  * @property {{ serializer?: 'json' | 'raw', extension?: string }} [options]
78
+ * @property {string} [namespace] - 캐시 키 자동 prefix `mega:cache:<namespace>:` (ADR-064/213, 베이스 처리).
79
+ * @property {number} [defaultTtlSec] - `set` ttl 미지정 시 디폴트(초). 0 = 무한 옵트인. (ADR-216, 베이스 처리)
78
80
  */
79
81
 
80
82
  /**
@@ -94,7 +96,8 @@ export class MegaFileAdapter extends MegaCacheAdapter {
94
96
  #extension
95
97
 
96
98
  /**
97
- * @param {FileConfig} [config] - services.caches.<key> 설정.
99
+ * @param {FileConfig} [config] - services.caches.<key> 설정. 베이스(MegaCacheAdapter)의
100
+ * `namespace`(ADR-064 자동 prefix)/`defaultTtlSec`(ADR-216) 도 여기서 받는다.
98
101
  * @throws {MegaValidationError} `adapter.basepath_required` - basePath/dir 누락.
99
102
  * @throws {MegaValidationError} `adapter.invalid_option` - 옵션 타입/미지원 키 오류.
100
103
  */
@@ -245,7 +248,7 @@ export class MegaFileAdapter extends MegaCacheAdapter {
245
248
  */
246
249
  async get(key) {
247
250
  return this._instrument('get', { key }, async () => {
248
- const path = this.#pathFor(key)
251
+ const path = this.#pathFor(this._cacheKey(key))
249
252
  let raw
250
253
  try {
251
254
  raw = await readFile(path, 'utf8')
@@ -278,6 +281,7 @@ export class MegaFileAdapter extends MegaCacheAdapter {
278
281
  // TTL·직렬화 검증은 의도적으로 `_instrument` **밖**(fail-fast). 잘못된 인자는 디스크 I/O·hook·stats
279
282
  // 이전에 거부 — 프로그래밍 오류를 instrumented 호출 통계에 섞지 않는다(L-1, redis 어댑터와 동일 결정).
280
283
  this._assertTtl(ttl)
284
+ ttl = this._resolveTtl(ttl, key) // 미지정 → defaultTtlSec(ADR-216). 디폴트 값은 생성자에서 검증됨.
281
285
  if (this.#serializer === 'raw' && typeof value !== 'string') {
282
286
  throw new MegaValidationError('cache.unserializable', `file set("${key}"): serializer='raw' requires a string value (got ${typeof value}).`, {
283
287
  details: { key, type: typeof value, serializer: 'raw' },
@@ -300,7 +304,7 @@ export class MegaFileAdapter extends MegaCacheAdapter {
300
304
  value,
301
305
  expiresAt: ttl !== undefined ? Date.now() + ttl * 1000 : null,
302
306
  }
303
- await this.#atomicWrite(this.#pathFor(key), envelope)
307
+ await this.#atomicWrite(this.#pathFor(this._cacheKey(key)), envelope)
304
308
  })
305
309
  }
306
310
 
@@ -312,7 +316,7 @@ export class MegaFileAdapter extends MegaCacheAdapter {
312
316
  async del(key) {
313
317
  return this._instrument('del', { key }, async () => {
314
318
  try {
315
- await unlink(this.#pathFor(key))
319
+ await unlink(this.#pathFor(this._cacheKey(key)))
316
320
  } catch (err) {
317
321
  // ENOENT = 이미 없음(del idempotent — 정상). 그 외 I/O 에러는 전파(무차별 삼킴 X).
318
322
  if (/** @type {any} */ (err)?.code !== 'ENOENT') throw err
@@ -331,7 +335,7 @@ export class MegaFileAdapter extends MegaCacheAdapter {
331
335
  */
332
336
  async has(key) {
333
337
  return this._instrument('has', { key }, async () => {
334
- const path = this.#pathFor(key)
338
+ const path = this.#pathFor(this._cacheKey(key))
335
339
  let raw
336
340
  try {
337
341
  raw = await readFile(path, 'utf8')
@@ -199,11 +199,12 @@ export class MegaFileSessionAdapter extends MegaSessionAdapter {
199
199
  }
200
200
 
201
201
  /**
202
- * 누적 통계 + file 세션 특화.
203
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, basePath: string, ttlMs: number }}
202
+ * 누적 통계 + file 세션 특화. `cleanupIntervalMs` 노출(0=내부 타이머 off) — 만료 스캔 주기의
203
+ * 적용 여부를 운영/테스트가 확인하는 단일 창구(ADR-215).
204
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, basePath: string, ttlMs: number, cleanupIntervalMs: number }}
204
205
  */
205
206
  getStats() {
206
- return { ...super.getStats(), driver: 'file', basePath: this.#basePath, ttlMs: this.#ttlMs }
207
+ return { ...super.getStats(), driver: 'file', basePath: this.#basePath, ttlMs: this.#ttlMs, cleanupIntervalMs: this.#cleanupIntervalMs }
207
208
  }
208
209
 
209
210
  /**
@@ -298,19 +298,22 @@ export class MegaMariaAdapter extends MegaDbAdapter {
298
298
  }
299
299
 
300
300
  /**
301
- * 누적 통계 + 풀 통계(total/idle/active/queue). 연결 전이면 통계는 0.
302
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, idle: number, active: number, queue: number } }}
301
+ * 누적 통계 + 풀 통계 — 공통 코어 키 `{ total, active, idle, waiting }` 4 driver 동일
302
+ * 형태(ADR-216 G2 H-2). `queue` 기존 소비자 하위 호환 별칭(= waiting). 연결 전이면 0.
303
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, active: number, idle: number, waiting: number, queue: number } }}
303
304
  */
304
305
  getStats() {
305
306
  const pool = this.#pool
307
+ const waiting = pool?.taskQueueSize() ?? 0
306
308
  return {
307
309
  ...super.getStats(),
308
310
  driver: 'mariadb',
309
311
  pool: {
310
312
  total: pool?.totalConnections() ?? 0,
311
- idle: pool?.idleConnections() ?? 0,
312
313
  active: pool?.activeConnections() ?? 0,
313
- queue: pool?.taskQueueSize() ?? 0,
314
+ idle: pool?.idleConnections() ?? 0,
315
+ waiting,
316
+ queue: waiting,
314
317
  },
315
318
  }
316
319
  }
@@ -5,10 +5,15 @@
5
5
  * `ctx.cache(key)` 가 본 베이스 인스턴스를 반환. 구체: `MegaRedisAdapter` /
6
6
  * `MegaFileAdapter` (ADR-082).
7
7
  *
8
- * ⚠️ **키 네임스페이스(ADR-064)**: ADR-064 `mega:cache:<appName>:<key>` 자동 prefix 를 *결정*했으나
9
- * **현재 코어에 미구현**이다(get/set/del raw `key` 그대로 사용). 멀티앱이 같은 redis 를 공유하면
10
- * 충돌 위험이 있으니, **현재는 사용자가 키에 앱별 네임스페이스를 직접 붙여야 한다**. 자동 prefix 구현은
11
- * 후속 과제(ADR-064 open). 단일앱( sample)에선 무관.
8
+ * **키 네임스페이스(ADR-064, 옵트인 — ADR-216 구현)**: `services.caches.<key>.namespace: '<name>'`
9
+ * 주면 get/set/del/has 키에 `mega:cache:<name>:` 자동 prefix 된다 — 멀티앱이 같은 redis 를
10
+ * 공유할 때의 충돌·상호 evict 차단(세션 `mega:sess:`·roster `ws:roster:` 대칭). 미지정
11
+ * 기존과 동일하게 raw key(하위 호환 — 기본 ON 전환은 메이저에서 재평가).
12
+ *
13
+ * **디폴트 TTL(ADR-216)**: `defaultTtlSec: <초>` 를 주면 `set` 의 ttl 미지정 호출에 적용된다 —
14
+ * 동적 키에 ttl 을 빠뜨려 무한 증가하는 풋건 방어. `defaultTtlSec: 0` = 무한 저장을 의식적으로
15
+ * 선택(경고 없음). 둘 다 미지정인 채 ttl 없는 set 이 호출되면 **인스턴스당 1회** process.emitWarning
16
+ * 으로 표면화한다(동작은 기존과 동일 — 무한 저장).
12
17
  *
13
18
  * @module adapters/mega-cache-adapter
14
19
  */
@@ -16,8 +21,21 @@ import { MegaInternalError, MegaValidationError } from '../errors/http-errors.js
16
21
  import { MegaAdapter } from './mega-adapter.js'
17
22
 
18
23
  export class MegaCacheAdapter extends MegaAdapter {
24
+ /** @type {string | null} ADR-064 자동 prefix (`mega:cache:<namespace>:`) — 미지정이면 null(raw key). */
25
+ #keyPrefix = null
26
+
27
+ /** @type {number | null} set 의 ttl 미지정 시 적용할 디폴트(초). null = 미설정. */
28
+ #defaultTtlSec = null
29
+
30
+ /** @type {boolean} `defaultTtlSec: 0` — 무한 저장 의식적 옵트인(경고 억제). */
31
+ #isInfiniteTtlOptIn = false
32
+
33
+ /** @type {boolean} 무기한 set 경고를 인스턴스당 1회로 제한. */
34
+ #hasWarnedNoTtl = false
35
+
19
36
  /**
20
- * @param {object} [config]
37
+ * @param {{ namespace?: string, defaultTtlSec?: number } & Record<string, any>} [config]
38
+ * @throws {MegaValidationError} `adapter.invalid_option` - namespace/defaultTtlSec 형식 오류.
21
39
  */
22
40
  constructor(config) {
23
41
  super(config)
@@ -28,6 +46,65 @@ export class MegaCacheAdapter extends MegaAdapter {
28
46
  { details: { class: 'MegaCacheAdapter' } },
29
47
  )
30
48
  }
49
+ const ns = config?.namespace
50
+ if (ns !== undefined) {
51
+ // 콜론·공백은 prefix 구조(`mega:cache:<ns>:<key>`)의 구분자 혼동을 만든다 — 명시 거부.
52
+ if (typeof ns !== 'string' || ns.length === 0 || /[\s:]/.test(ns)) {
53
+ throw new MegaValidationError(
54
+ 'adapter.invalid_option',
55
+ `cache "namespace" must be a non-empty string without spaces/colons (got ${JSON.stringify(ns)}).`,
56
+ { details: { namespace: ns ?? null } },
57
+ )
58
+ }
59
+ this.#keyPrefix = `mega:cache:${ns}:`
60
+ }
61
+ const ttl = config?.defaultTtlSec
62
+ if (ttl !== undefined) {
63
+ if (ttl === 0) {
64
+ this.#isInfiniteTtlOptIn = true
65
+ } else if (!Number.isInteger(ttl) || ttl < 0) {
66
+ throw new MegaValidationError(
67
+ 'adapter.invalid_option',
68
+ `cache "defaultTtlSec" must be a non-negative integer (0 = 무한 저장 옵트인, got ${JSON.stringify(ttl)}).`,
69
+ { details: { defaultTtlSec: ttl } },
70
+ )
71
+ } else {
72
+ this.#defaultTtlSec = ttl
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 네임스페이스 적용 키 — 구체 어댑터의 get/set/del/has 가 저장소 접근 직전에 경유한다(ADR-064).
79
+ * @protected
80
+ * @param {string} key
81
+ * @returns {string}
82
+ */
83
+ _cacheKey(key) {
84
+ return this.#keyPrefix === null ? key : this.#keyPrefix + key
85
+ }
86
+
87
+ /**
88
+ * `set` 의 유효 TTL 해석 — 명시 ttl 우선, 없으면 defaultTtlSec. 둘 다 없으면 무한 저장이며
89
+ * `defaultTtlSec: 0` 옵트인이 아닌 한 인스턴스당 1회 경고를 낸다(키 무한 증가 풋건 표면화 —
90
+ * 동작은 기존과 동일, ADR-216).
91
+ * @protected
92
+ * @param {number} [ttl] - 호출자가 명시한 ttl(초).
93
+ * @param {string} [key] - 경고 메시지용 예시 키.
94
+ * @returns {number | undefined} 적용할 ttl(초) — undefined 면 무한.
95
+ */
96
+ _resolveTtl(ttl, key) {
97
+ if (ttl !== undefined) return ttl
98
+ if (this.#defaultTtlSec !== null) return this.#defaultTtlSec
99
+ if (!this.#isInfiniteTtlOptIn && !this.#hasWarnedNoTtl) {
100
+ this.#hasWarnedNoTtl = true
101
+ process.emitWarning(
102
+ `cache.set("${key ?? '?'}") without ttl and no defaultTtlSec configured — keys never expire and can grow unbounded. ` +
103
+ `Set services.caches.<key>.defaultTtlSec (or pass { ttl }), or defaultTtlSec: 0 to opt in to unbounded storage.`,
104
+ { code: 'MEGA_CACHE_NO_TTL' },
105
+ )
106
+ }
107
+ return undefined
31
108
  }
32
109
 
33
110
  /**
@@ -41,7 +118,7 @@ export class MegaCacheAdapter extends MegaAdapter {
41
118
  /**
42
119
  * @param {string} _key
43
120
  * @param {any} _value
44
- * @param {{ ttl?: number }} [_opts] - `ttl` 미지정 시 무한 (초 단위).
121
+ * @param {{ ttl?: number }} [_opts] - `ttl` 미지정 시 defaultTtlSec, 그것도 없으면 무한(초 단위, ADR-216).
45
122
  * @returns {Promise<void>}
46
123
  */
47
124
  async set(_key, _value, _opts = {}) {
@@ -30,7 +30,10 @@ export class MegaDbAdapter extends MegaAdapter {
30
30
  * driver 별 구현 (postgres `BEGIN/COMMIT/ROLLBACK`, MongoDB `session.withTransaction`).
31
31
  * nested 호출은 driver 별 (postgres SAVEPOINT, MongoDB throw `adapter.nested_transaction_unsupported`).
32
32
  *
33
- * `opts.isolation`(ADR-190) — SQL 격리수준 옵트인. 미지정이면 driver 디폴트. driver 별 지원:
33
+ * `opts.isolation`(ADR-190) — SQL 격리수준 옵트인. **미지정이면 driver 디폴트가 적용되며
34
+ * 디폴트는 driver 마다 다르다**(ADR-216 G2 M-2): postgres = READ COMMITTED,
35
+ * mariadb(InnoDB) = REPEATABLE READ — 같은 `withTransaction(fn)` 코드가 driver 에 따라 다른
36
+ * 동시성 의미를 가진다. 이식성 있는 동시성 가정이 필요하면 isolation 을 명시할 것. driver 별 지원:
34
37
  * postgres/maria 는 top-level 트랜잭션에 `SET TRANSACTION ISOLATION LEVEL` 로 반영(nested 엔 불가 —
35
38
  * `adapter.nested_isolation_unsupported`), sqlite 는 항상 SERIALIZABLE 동작이라 'serializable' 만 수용,
36
39
  * mongodb 는 SQL 격리수준 개념이 없어 지정 시 `adapter.invalid_option`.
@@ -137,6 +137,7 @@ function buildMongoUri({ host, port, user, password }) {
137
137
  * @typedef {object} PoolCounters
138
138
  * @property {number} created @property {number} closed
139
139
  * @property {number} checkedOut @property {number} checkedIn
140
+ * @property {number} checkOutStarted @property {number} checkOutFailed
140
141
  */
141
142
 
142
143
  export class MegaMongoAdapter extends MegaDbAdapter {
@@ -162,7 +163,7 @@ export class MegaMongoAdapter extends MegaDbAdapter {
162
163
  */
163
164
  #txContext = new AsyncLocalStorage()
164
165
  /** @type {PoolCounters} CMAP 이벤트 누적 풀 카운터. */
165
- #pool = { created: 0, closed: 0, checkedOut: 0, checkedIn: 0 }
166
+ #pool = { created: 0, closed: 0, checkedOut: 0, checkedIn: 0, checkOutStarted: 0, checkOutFailed: 0 }
166
167
  /**
167
168
  * 등록한 CMAP 리스너 — disconnect 시 정확히 제거하기 위해 보관(누수·재연결 시 중복 방지).
168
169
  * @type {Array<[string, (...args: any[]) => void]>}
@@ -296,23 +297,33 @@ export class MegaMongoAdapter extends MegaDbAdapter {
296
297
  }
297
298
 
298
299
  /**
299
- * 누적 통계 + mongo 특화(driver/dbName + CMAP 풀 카운터). 연결 전이면 카운터는 0.
300
- * @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 } }}
300
+ * 누적 통계 + mongo 특화(driver/dbName + CMAP 풀 카운터). 공통 코어
301
+ * `{ total, active, idle, waiting }` 4 driver 동일 형태(ADR-216 G2 H-2 CMAP 누적
302
+ * 카운터에서 파생: total=created-closed, active=checkedOut-checkedIn,
303
+ * waiting=checkOutStarted-(checkedOut+checkOutFailed)). 누적 원본도 유지(하위 호환).
304
+ * 연결 전이면 전부 0.
305
+ * @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 } }}
301
306
  */
302
307
  getStats() {
303
- const { created, closed, checkedOut, checkedIn } = this.#pool
308
+ const { created, closed, checkedOut, checkedIn, checkOutStarted, checkOutFailed } = this.#pool
309
+ const total = created - closed
310
+ const active = checkedOut - checkedIn
304
311
  return {
305
312
  ...super.getStats(),
306
313
  driver: 'mongodb',
307
314
  dbName: this.#dbName,
308
315
  pool: {
316
+ total,
317
+ active,
318
+ idle: total - active,
319
+ waiting: checkOutStarted - checkedOut - checkOutFailed,
309
320
  created,
310
321
  closed,
311
322
  checkedOut,
312
323
  checkedIn,
313
- // 파생값 — open=살아있는 연결 수, inUse=현재 체크아웃된(작업 중) 연결 수.
314
- open: created - closed,
315
- inUse: checkedOut - checkedIn,
324
+ // 파생값(기존 표면 유지) — open=살아있는 연결 수, inUse=현재 체크아웃된(작업 중) 연결 수.
325
+ open: total,
326
+ inUse: active,
316
327
  },
317
328
  }
318
329
  }
@@ -389,6 +400,9 @@ export class MegaMongoAdapter extends MegaDbAdapter {
389
400
  ['connectionClosed', () => (this.#pool.closed += 1)],
390
401
  ['connectionCheckedOut', () => (this.#pool.checkedOut += 1)],
391
402
  ['connectionCheckedIn', () => (this.#pool.checkedIn += 1)],
403
+ // 공통 풀 키의 waiting 파생용(ADR-216) — started - (out + failed) = 현재 대기 수.
404
+ ['connectionCheckOutStarted', () => (this.#pool.checkOutStarted += 1)],
405
+ ['connectionCheckOutFailed', () => (this.#pool.checkOutFailed += 1)],
392
406
  ]
393
407
  for (const [event, handler] of listeners) {
394
408
  client.on(event, handler)
@@ -216,17 +216,21 @@ export class MegaPostgresAdapter extends MegaDbAdapter {
216
216
  }
217
217
 
218
218
  /**
219
- * 누적 통계 + 풀 통계(total/idle/waiting). 연결 전이면 통계는 0.
220
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, idle: number, waiting: number } }}
219
+ * 누적 통계 + 풀 통계 — 공통 코어 키 `{ total, active, idle, waiting }` 4 driver 동일
220
+ * 형태(ADR-216 G2 H-2: 운영 대시보드가 driver 무관 코드로 "풀 포화" 묻게). 연결 전이면 0.
221
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, pool: { total: number, active: number, idle: number, waiting: number } }}
221
222
  */
222
223
  getStats() {
223
224
  const pool = this.#pool
225
+ const total = pool?.totalCount ?? 0
226
+ const idle = pool?.idleCount ?? 0
224
227
  return {
225
228
  ...super.getStats(),
226
229
  driver: 'postgres',
227
230
  pool: {
228
- total: pool?.totalCount ?? 0,
229
- idle: pool?.idleCount ?? 0,
231
+ total,
232
+ active: total - idle,
233
+ idle,
230
234
  waiting: pool?.waitingCount ?? 0,
231
235
  },
232
236
  }
@@ -61,6 +61,8 @@ import * as Registry from './registry.js'
61
61
  * @property {string} [host] @property {number} [port] @property {string} [user] @property {string} [password]
62
62
  * @property {number} [db] - 논리 DB 번호 0~15 (connection 과 별개 축, url path 보다 우선).
63
63
  * @property {any} [pool] - 미지원 — 지정 시 `adapter.invalid_option` throw (Redis 는 풀 모델 아님, ADR-110).
64
+ * @property {string} [namespace] - 캐시 키 자동 prefix `mega:cache:<namespace>:` (ADR-064/213 — 멀티앱 충돌 차단, 옵트인).
65
+ * @property {number} [defaultTtlSec] - `set` 의 ttl 미지정 시 적용할 디폴트(초). 0 = 무한 저장 옵트인(경고 억제). (ADR-216)
64
66
  * @property {Record<string, any>} [options] - ioredis passthrough (keyPrefix, commandTimeout, tls, retryStrategy, keepAlive, …).
65
67
  */
66
68
 
@@ -267,7 +269,7 @@ export class MegaRedisAdapter extends MegaCacheAdapter {
267
269
  */
268
270
  async get(key) {
269
271
  return this._instrument('get', { key }, async () => {
270
- const raw = await /** @type {import('ioredis').Redis} */ (this.#client).get(key)
272
+ const raw = await /** @type {import('ioredis').Redis} */ (this.#client).get(this._cacheKey(key))
271
273
  if (raw === null) return null // miss
272
274
  return JSON.parse(raw)
273
275
  })
@@ -287,6 +289,8 @@ export class MegaRedisAdapter extends MegaCacheAdapter {
287
289
  // I/O·hook·stats 누적 이전에 fail-fast 로 거부하는 게 맞다(인자 오류는 어댑터 "호출"이 아니라
288
290
  // 프로그래밍 오류 — instrumented 호출 통계에 섞이면 안 됨). 정상 경로의 실제 I/O 만 _instrument 가 감싼다.
289
291
  this._assertTtl(ttl)
292
+ ttl = this._resolveTtl(ttl, key) // 미지정 → defaultTtlSec(ADR-216). 디폴트 값은 생성자에서 검증됨.
293
+ key = this._cacheKey(key)
290
294
  const raw = JSON.stringify(value)
291
295
  if (raw === undefined) {
292
296
  // JSON.stringify(undefined/함수/심볼) === undefined — 저장 시 silent 손상 대신 명시 거부.
@@ -309,7 +313,7 @@ export class MegaRedisAdapter extends MegaCacheAdapter {
309
313
  */
310
314
  async del(key) {
311
315
  return this._instrument('del', { key }, async () => {
312
- await /** @type {import('ioredis').Redis} */ (this.#client).del(key)
316
+ await /** @type {import('ioredis').Redis} */ (this.#client).del(this._cacheKey(key))
313
317
  })
314
318
  }
315
319
 
@@ -320,7 +324,7 @@ export class MegaRedisAdapter extends MegaCacheAdapter {
320
324
  */
321
325
  async has(key) {
322
326
  return this._instrument('has', { key }, async () => {
323
- const n = await /** @type {import('ioredis').Redis} */ (this.#client).exists(key)
327
+ const n = await /** @type {import('ioredis').Redis} */ (this.#client).exists(this._cacheKey(key))
324
328
  return n === 1
325
329
  })
326
330
  }
@@ -219,10 +219,11 @@ export class MegaSqliteAdapter extends MegaDbAdapter {
219
219
  }
220
220
 
221
221
  /**
222
- * 누적 통계 + sqlite 특화 필드.
223
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, filename: string, inMemory: boolean, readonly: boolean, journalMode: string | undefined }}
222
+ * 누적 통계 + sqlite 특화 필드 + 공통 풀 코어 키(합성 — 단일 연결, ADR-216).
223
+ * @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 } }}
224
224
  */
225
225
  getStats() {
226
+ const connected = this.state === 'connected'
226
227
  return {
227
228
  ...super.getStats(),
228
229
  driver: 'sqlite',
@@ -231,6 +232,9 @@ export class MegaSqliteAdapter extends MegaDbAdapter {
231
232
  readonly: this.#openOptions.readonly,
232
233
  // _connect() 에서 캐시한 불변값 사용 — 미연결이면 undefined(매 호출 PRAGMA 실행 제거, L-4).
233
234
  journalMode: this.#journalMode,
235
+ // 공통 풀 코어 키(ADR-216 G2 H-2) — sqlite 는 connection-per-Database 단일 동기 연결이라
236
+ // 풀이 없다. 쿼리가 동기라 stats 를 읽는 시점엔 항상 유휴(active 0/waiting 0) — 합성 값.
237
+ pool: connected ? { total: 1, active: 0, idle: 1, waiting: 0 } : { total: 0, active: 0, idle: 0, waiting: 0 },
234
238
  }
235
239
  }
236
240