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.
- package/README.md +9 -0
- package/package.json +3 -3
- package/sample/crud/.env +9 -0
- package/sample/crud/.env.example +9 -0
- package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
- package/sample/crud/apps/main/locales/server/en.json +12 -1
- package/sample/crud/apps/main/locales/server/ko.json +12 -1
- package/sample/crud/apps/main/routes/upload.js +20 -1
- package/sample/crud/apps/main/services/guide-service.js +4 -3
- package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
- package/sample/crud/apps/main/views/upload/index.ejs +4 -1
- package/sample/crud/docs/guide/01-cli.md +587 -0
- package/sample/crud/docs/guide/02-router-controller.md +497 -0
- package/sample/crud/docs/guide/03-service-model-db.md +929 -0
- package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +400 -0
- package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
- package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
- package/sample/crud/docs/guide/08-observability.md +373 -0
- package/sample/crud/mega.config.js +7 -0
- package/sample/crud/package.json +2 -2
- package/sample/crud/scripts/start-ws-hub.sh +18 -4
- 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
- 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
- package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
- package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
- package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
- 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
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/adapters/adapter-options.js +14 -3
- package/src/adapters/file-adapter.js +9 -5
- package/src/adapters/file-session-adapter.js +4 -3
- package/src/adapters/maria-adapter.js +7 -4
- package/src/adapters/mega-cache-adapter.js +83 -6
- package/src/adapters/mega-db-adapter.js +4 -1
- package/src/adapters/mongo-adapter.js +21 -7
- package/src/adapters/postgres-adapter.js +8 -4
- package/src/adapters/redis-adapter.js +7 -3
- package/src/adapters/sqlite-adapter.js +6 -2
- package/src/cli/commands/console-cmd.js +3 -1
- package/src/cli/commands/scaffold.js +38 -2
- package/src/cli/generators/index.js +58 -1
- package/src/cli/index.js +88 -59
- package/src/cli/watch.js +188 -0
- package/src/core/ajv-mapper.js +3 -1
- package/src/core/ctx-builder.js +59 -1
- package/src/core/envelope.js +9 -2
- package/src/core/hub-link.js +24 -14
- package/src/core/index.js +1 -1
- package/src/core/mega-app.js +55 -45
- package/src/core/pipeline.js +8 -6
- package/src/core/scope-registry.js +1 -0
- package/src/core/security.js +3 -3
- package/src/core/session-store.js +14 -1
- package/src/core/ws-presence.js +17 -5
- package/src/core/ws-roster.js +49 -10
- package/src/core/ws-upgrade.js +105 -0
- package/src/lib/mega-circuit-breaker.js +5 -3
- package/src/lib/mega-health.js +10 -0
- package/src/lib/mega-job-queue.js +53 -13
- package/src/lib/mega-job.js +8 -1
- package/src/lib/mega-metrics.js +28 -1
- package/src/lib/mega-plugin.js +2 -2
- package/src/lib/mega-worker.js +28 -5
- package/src/lib/ws-hub.js +90 -9
- package/templates/adr/code.tpl +23 -0
- package/types/adapters/adapter-options.d.ts +2 -0
- package/types/adapters/file-adapter.d.ts +12 -1
- package/types/adapters/file-session-adapter.d.ts +4 -2
- package/types/adapters/maria-adapter.d.ts +5 -3
- package/types/adapters/mega-cache-adapter.d.ts +27 -1
- package/types/adapters/mega-db-adapter.d.ts +4 -1
- package/types/adapters/mongo-adapter.d.ts +13 -2
- package/types/adapters/postgres-adapter.d.ts +4 -2
- package/types/adapters/redis-adapter.d.ts +8 -0
- package/types/adapters/sqlite-adapter.d.ts +8 -2
- package/types/cli/generators/index.d.ts +11 -1
- package/types/cli/index.d.ts +12 -27
- package/types/cli/watch.d.ts +59 -0
- package/types/core/ctx-builder.d.ts +23 -0
- package/types/core/hub-link.d.ts +3 -1
- package/types/core/index.d.ts +1 -1
- package/types/core/mega-app.d.ts +1 -1
- package/types/core/pipeline.d.ts +2 -1
- package/types/core/security.d.ts +3 -3
- package/types/core/session-store.d.ts +7 -0
- package/types/core/ws-roster.d.ts +13 -1
- package/types/core/ws-upgrade.d.ts +29 -0
- package/types/lib/mega-circuit-breaker.d.ts +4 -2
- package/types/lib/mega-health.d.ts +7 -0
- package/types/lib/mega-job-queue.d.ts +16 -4
- package/types/lib/mega-job.d.ts +8 -1
- package/types/lib/mega-plugin.d.ts +1 -1
- package/types/lib/mega-worker.d.ts +3 -1
- 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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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 격리수준 옵트인.
|
|
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 풀 카운터).
|
|
300
|
-
*
|
|
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:
|
|
315
|
-
inUse:
|
|
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
|
-
* 누적 통계 + 풀 통계
|
|
220
|
-
*
|
|
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
|
|
229
|
-
|
|
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 {
|
|
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
|