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
@@ -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
 
@@ -9,7 +9,6 @@
9
9
  * @module cli/commands/console-cmd
10
10
  */
11
11
  import repl from 'node:repl'
12
- import { prepareRuntime } from '../../core/boot.js'
13
12
  import { MegaShutdown } from '../../lib/mega-shutdown.js'
14
13
 
15
14
  /**
@@ -38,6 +37,9 @@ export async function startConsole(
38
37
  projectRoot,
39
38
  { logger, replFactory = defaultReplFactory, out = console.log, shutdown = () => MegaShutdown.now(), setupSignals = (opts) => MegaShutdown.setupSignals(opts) } = {},
40
39
  ) {
40
+ // boot 그래프(fastify·OTel·pino 등)는 콘솔 기동 시점에만 로드 — scaffold.js 가 본 모듈을 정적
41
+ // import 하므로, 여기서 정적 import 하면 `mega help`/`g` 까지 전체 그래프를 지불하게 된다.
42
+ const { prepareRuntime } = await import('../../core/boot.js')
41
43
  const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
42
44
  out('mega: console ready — globals: ctx, config, mega')
43
45
  // prepareRuntime 이 어댑터/워커/wsHub 를 connect 하고 각자 MegaShutdown hook 을 자기등록한다. 정리 없이
@@ -13,7 +13,9 @@ import { Command } from 'commander'
13
13
  import { existsSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import { pathToFileURL } from 'node:url'
16
- import { generate, generateFromScaffoldDef, GENERATOR_KINDS } from '../generators/index.js'
16
+ import { execFile } from 'node:child_process'
17
+ import { promisify } from 'node:util'
18
+ import { generate, generateFromScaffoldDef, nextAdrNumber, GENERATOR_KINDS } from '../generators/index.js'
17
19
  import { scaffoldProject } from './new.js'
18
20
  import { runRoutesCommand } from './routes.js'
19
21
  import { runTestCommand } from './test-cmd.js'
@@ -31,6 +33,36 @@ function reportFiles(out, r, root) {
31
33
  for (const f of r.skipped) out(` skip ${f.startsWith(root) ? f.slice(root.length + 1) : f} (exists — use --force)`)
32
34
  }
33
35
 
36
+ const execFileAsync = promisify(execFile)
37
+
38
+ /**
39
+ * `g adr` 의 다음 번호를 **원격 포함**으로 해석한다(ADR-218 — 병렬 task 번호 충돌 회피).
40
+ * `git fetch origin` 후 `origin/main` 의 `docs/adr/` 파일명에서 번호를 모아 로컬 스캔
41
+ * ({@link nextAdrNumber})과 합산한다 — 형제 task 가 방금 push 한 ADR 이 로컬 작업트리에 없어도
42
+ * 번호가 건너뛰어진다. git 부재/오프라인/비-repo 는 로컬 스캔만으로 폴백(경고 1줄 — 스캐폴드는
43
+ * 어디서든 동작해야 하므로 fail 아님).
44
+ *
45
+ * @param {string} projectRoot
46
+ * @param {(msg: string) => void} out
47
+ * @returns {Promise<number>}
48
+ */
49
+ async function resolveAdrNumberWithRemote(projectRoot, out) {
50
+ /** @type {number[]} */
51
+ const remote = []
52
+ try {
53
+ await execFileAsync('git', ['fetch', 'origin', '--quiet'], { cwd: projectRoot })
54
+ const { stdout } = await execFileAsync('git', ['ls-tree', '--name-only', 'origin/main', 'docs/adr/'], { cwd: projectRoot })
55
+ for (const line of stdout.split('\n')) {
56
+ const m = line.match(/(\d{1,4})-[^/]+\.md$/)
57
+ if (m) remote.push(Number(m[1]))
58
+ }
59
+ } catch (err) {
60
+ // 오프라인/비-repo/origin 부재 — 로컬 스캔만으로 진행하되 충돌 가능성을 알린다(silent 금지).
61
+ out(`mega: 원격 ADR 번호 확인 실패(${/** @type {any} */ (err).message?.split('\n')[0] ?? err}) — 로컬 기준으로 번호를 할당합니다.`)
62
+ }
63
+ return nextAdrNumber(projectRoot, remote)
64
+ }
65
+
34
66
  /**
35
67
  * `g model --adapter <key>` 의 adapter 키/driver 해석 — mega.config.js 의 services.databases 를
36
68
  * best-effort 로 읽는다(스캐폴드는 config 가 아직 없는 초기 프로젝트에서도 돌아야 하므로 로드
@@ -109,10 +141,14 @@ export function registerScaffoldCommands(program, { out, projectRoot, logger, re
109
141
  /** @type {{ key: string, driver: string | undefined }} */
110
142
  let modelAdapter = { key: 'primary', driver: undefined }
111
143
  if (kind === 'model') modelAdapter = await resolveModelAdapter(projectRoot, opts.adapter, out)
144
+ // adr 은 번호를 원격 포함으로 해석한다(병렬 task 충돌 회피, ADR-218).
145
+ /** @type {number | undefined} */
146
+ let adrNumber
147
+ if (kind === 'adr') adrNumber = await resolveAdrNumberWithRemote(projectRoot, out)
112
148
  r = generate(
113
149
  kind,
114
150
  name,
115
- { app: opts.app, version: opts.version, kind: opts.kind, lng: opts.lng, force: opts.force === true, adapter: modelAdapter.key, adapterDriver: modelAdapter.driver },
151
+ { app: opts.app, version: opts.version, kind: opts.kind, lng: opts.lng, force: opts.force === true, adapter: modelAdapter.key, adapterDriver: modelAdapter.driver, adrNumber },
116
152
  projectRoot,
117
153
  )
118
154
  } else {
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * @module cli/generators
15
15
  */
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
17
17
  import { dirname, join, relative, resolve, sep } from 'node:path'
18
18
  import { fileURLToPath } from 'node:url'
19
19
  import { nameVariants, renderTemplate } from '../template-engine.js'
@@ -36,6 +36,7 @@ export const GENERATOR_KINDS = /** @type {const} */ ([
36
36
  'locale',
37
37
  'adapter',
38
38
  'migration',
39
+ 'adr',
39
40
  ])
40
41
 
41
42
  /**
@@ -185,11 +186,67 @@ export function planArtifacts(kind, rawName, opts, projectRoot) {
185
186
  case 'app':
186
187
  return planApp(v, projectRoot, base)
187
188
 
189
+ case 'adr':
190
+ return planAdr(v, opts, projectRoot)
191
+
188
192
  default:
189
193
  throw new Error(`Unknown generator kind '${kind}'. Supported: ${GENERATOR_KINDS.join(', ')}.`)
190
194
  }
191
195
  }
192
196
 
197
+ /**
198
+ * 다음 ADR 번호를 해석한다 — `docs/adr/NNNN-*.md` 파일명 + 레거시 `docs/09` 헤딩(`### ADR-N:`) +
199
+ * 호출측이 모은 추가 번호(예: `git ls-tree origin/main` 의 원격 파일 — 병렬 task 충돌 회피)의
200
+ * 최댓값 + 1. 아무 ADR 도 없으면 1.
201
+ *
202
+ * @param {string} projectRoot
203
+ * @param {number[]} [extraNumbers] - 로컬 밖에서 관측한 번호(원격 스캔 등).
204
+ * @returns {number}
205
+ */
206
+ export function nextAdrNumber(projectRoot, extraNumbers = []) {
207
+ let max = 0
208
+ const adrDir = join(projectRoot, 'docs/adr')
209
+ if (existsSync(adrDir)) {
210
+ for (const f of readdirSync(adrDir)) {
211
+ const m = f.match(/^(\d{1,4})-.+\.md$/)
212
+ if (m) max = Math.max(max, Number(m[1]))
213
+ }
214
+ }
215
+ const legacy = join(projectRoot, 'docs/09-decisions-and-open-questions.md')
216
+ if (existsSync(legacy)) {
217
+ for (const m of readFileSync(legacy, 'utf8').matchAll(/^### ADR-(\d+)/gm)) {
218
+ max = Math.max(max, Number(m[1]))
219
+ }
220
+ }
221
+ for (const n of extraNumbers) {
222
+ if (Number.isInteger(n)) max = Math.max(max, n)
223
+ }
224
+ return max + 1
225
+ }
226
+
227
+ /** adr — `docs/adr/NNNN-<name>.md` 1개(코드/테스트 쌍 아님 — 프로젝트 결정 기록 문서, ADR-218).
228
+ * 번호는 opts.adrNumber(호출측이 원격 포함 해석) 우선, 미지정 시 로컬 스캔({@link nextAdrNumber}).
229
+ * @param {Variants} v @param {Record<string, any>} opts @param {string} projectRoot
230
+ * @returns {Artifact[]} */
231
+ function planAdr(v, opts, projectRoot) {
232
+ const number = Number.isInteger(opts.adrNumber) && opts.adrNumber > 0 ? opts.adrNumber : nextAdrNumber(projectRoot)
233
+ const padded = String(number).padStart(4, '0')
234
+ const d = new Date()
235
+ const p = (/** @type {number} */ n) => String(n).padStart(2, '0')
236
+ const vars = {
237
+ number: String(number),
238
+ title: v.words.join(' '),
239
+ date: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`,
240
+ }
241
+ return [
242
+ {
243
+ outAbs: join(projectRoot, `docs/adr/${padded}-${v.kebab}.md`),
244
+ role: 'code',
245
+ content: renderTemplate(readTpl('adr', 'code.tpl'), vars),
246
+ },
247
+ ]
248
+ }
249
+
193
250
  /**
194
251
  * @typedef {{ kebab: string, pascal: string, camel: string, snake: string, words: string[] }} Variants
195
252
  * @typedef {Record<string, string>} BaseVars