mega-framework 0.1.8 → 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/package.json +1 -1
- package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
- 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/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
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
# Service + Model + DB
|
|
2
|
+
|
|
3
|
+
데이터 한 줄을 화면에 보여주기까지 MEGA-FRAMEWORK 는 세 층을 지난다.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
컨트롤러(라우트 핸들러) ──▶ 서비스(MegaService) ──▶ 모델(MegaModel) ──▶ DB 어댑터
|
|
7
|
+
ctx.services.user 비즈니스 로직 SQL/도큐먼트 호출 postgres/mongo/...
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- **컨트롤러는 모델을 직접 부르지 않는다.** 항상 서비스를 거친다(ADR-022). ESLint 룰
|
|
11
|
+
`mega/no-direct-model-import` 가 라우트·컨트롤러 → 모델 직접 import 를 lint 시점에 막는다.
|
|
12
|
+
- **모델은 ORM 이 없다.** 쿼리 빌더 없이 어댑터의 native 핸들(pg Pool / MongoClient `Db` / ...)로
|
|
13
|
+
SQL·도큐먼트 호출을 직접 쓴다(ADR-009). 그 대신 코드가 무엇을 하는지 한눈에 보인다.
|
|
14
|
+
- **어댑터는 전역에서 한 번** 만들어 공유한다(ADR-102). 여러 앱이 같은 DB 를 써도 연결 풀은 1개다.
|
|
15
|
+
|
|
16
|
+
이 문서는 위 세 층을 `mega.config.js` 설정부터 `mega migrate` 까지 순서대로 설명한다. 예시는
|
|
17
|
+
모두 `sample/crud` 의 실제 코드다.
|
|
18
|
+
|
|
19
|
+
> 관련 ADR: 022(모델 레이어), 108(withTransaction), 109(어댑터 옵션), 148(ctx.services 자동 DI),
|
|
20
|
+
> 149(mega migrate), 150(빌트인 어댑터 자기등록), 167(라우트 핸들러 시그니처).
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 1. mega.config.js — DB 어댑터 등록 (per-database, ADR-109)
|
|
25
|
+
|
|
26
|
+
DB 연결은 **전역 설정** `mega.config.js` 의 `services.databases.<key>` 에 한 번 선언한다. `<key>`
|
|
27
|
+
(=globalKey)가 어댑터 인스턴스의 이름이 된다.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
// mega.config.js — 전역 자원만 둔다(ADR-061)
|
|
31
|
+
export default {
|
|
32
|
+
apps: ['main'],
|
|
33
|
+
services: {
|
|
34
|
+
databases: {
|
|
35
|
+
// globalKey 'primary' — 모델(static adapter='primary')·마이그레이션·ctx.db('db') 가 공유한다.
|
|
36
|
+
primary: {
|
|
37
|
+
driver: 'postgres',
|
|
38
|
+
url: process.env.DATABASE_URL,
|
|
39
|
+
},
|
|
40
|
+
// globalKey 'mongo' — Document DB(ADR-108). Note 모델(static adapter='mongo')이 쓴다.
|
|
41
|
+
mongo: {
|
|
42
|
+
driver: 'mongodb',
|
|
43
|
+
url: process.env.MONGO_URL,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 한 DB = 한 블록, 공통 default 블록은 없다
|
|
51
|
+
|
|
52
|
+
각 DB 는 `{ driver, ...연결·옵션 }` 한 블록으로 **독립** 선언한다. "모든 DB 에 공통으로 적용되는
|
|
53
|
+
default 블록" 같은 건 **없다** — 만들지 말 것(§7 함정). DB 마다 driver 가 다르고 옵션 의미도 다르기
|
|
54
|
+
때문이다.
|
|
55
|
+
|
|
56
|
+
블록의 공통 모양(ADR-109, postgres/maria/mongo 공유):
|
|
57
|
+
|
|
58
|
+
| 키 | 뜻 |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `driver` | 어댑터 종류(`postgres`/`mariadb`/`mongodb`/`sqlite`/`redis`/...). 매니저만 쓰고 어댑터는 무시. |
|
|
61
|
+
| `url` **또는** discrete(`host`/`port`/`user`/`password`/`database`) | 연결 정보. **둘 중 하나만**(상호 배타). |
|
|
62
|
+
| `pool` | 공통 풀 인터페이스 `{ min, max, idleTimeoutMs, acquireTimeoutMs, maxLifetimeMs }`. |
|
|
63
|
+
| `options` | 드라이버 특화 passthrough(그대로 driver 에 전달). |
|
|
64
|
+
|
|
65
|
+
- `url` + discrete 동시 지정 → 부팅 시 `adapter.connection_conflict` (어느 쪽이 이기는지 모호하므로 거부).
|
|
66
|
+
- 둘 다 없음 → `adapter.connection_required`.
|
|
67
|
+
- `url` + `pool`/`options` 조합은 **허용** — url 은 연결 정보, pool/options 는 별도 축이다.
|
|
68
|
+
|
|
69
|
+
### .env 로 배포별 오버라이드 (envPrefix, ADR-109)
|
|
70
|
+
|
|
71
|
+
블록에 `envPrefix` 를 주면 `MEGA_<PREFIX>_*` 환경변수가 파일 값 **위에** 병합된다(env 가 이긴다).
|
|
72
|
+
12-factor 배포에서 코드를 안 고치고 호스트·비밀번호만 바꿀 때 쓴다.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
databases: {
|
|
76
|
+
primary: {
|
|
77
|
+
driver: 'postgres',
|
|
78
|
+
envPrefix: 'PRIMARY', // MEGA_PRIMARY_URL / MEGA_PRIMARY_POOL_MAX / MEGA_PRIMARY_OPTIONS_SSL ...
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `envPrefix` 자체는 어댑터에 안 넘어간다(드라이버 무관 메타).
|
|
84
|
+
- env-mapper 는 `driver` 를 보고 `OPTIONS_*` 키를 드라이버 표기(camel/snake)로 변환한다 —
|
|
85
|
+
camelCase 드라이버에 snake 키가 조용히 무시되지 않는다.
|
|
86
|
+
- `pool`/`options` 는 파일 값과 env 값을 1단계 deep-merge 한다.
|
|
87
|
+
|
|
88
|
+
### 앱은 별명으로 참조한다 (app.config.js)
|
|
89
|
+
|
|
90
|
+
전역 globalKey 를 앱이 직접 쓰지 않고 `app.config.js` 에서 **별명**을 붙여 쓴다(ADR-102 글로벌 공유).
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
// apps/main/app.config.js
|
|
94
|
+
export default {
|
|
95
|
+
name: 'main',
|
|
96
|
+
// 별명 'db' → globalKey 'primary', 별명 'mongo' → globalKey 'mongo'
|
|
97
|
+
databases: { db: 'primary', mongo: 'mongo' },
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
이러면 라우트 핸들러는 `ctx.db('db')`(별명)로, 모델은 `static adapter = 'primary'`(globalKey 직접)로
|
|
102
|
+
**같은** 공유 인스턴스에 닿는다. 둘의 lookup 경로는 §2 에서 비교한다.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 2. Model — MegaModel
|
|
107
|
+
|
|
108
|
+
`MegaModel` 은 **얇은 베이스**다. ORM·쿼리 빌더는 의도적으로 없다(ADR-009). 베이스가 주는 건 딱 셋:
|
|
109
|
+
|
|
110
|
+
| 멤버 | 하는 일 |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `static get db` | 어댑터의 **native 핸들** 반환(pg `Pool` / MongoClient `Db` / mariadb `Pool` / better-sqlite3 `Database`). 트레이싱 미통과. |
|
|
113
|
+
| `static query(sql, params)` | **계측된** SQL 실행(ADR-138). 어댑터 `query` 도메인 메서드에 위임 → 자동 span·메트릭. SQL 어댑터 전용(mongo 는 `adapter.not_implemented`). |
|
|
114
|
+
| `static withTransaction(fn)` | 명시적 트랜잭션 경계(ADR-108/010). 어댑터에 위임. |
|
|
115
|
+
|
|
116
|
+
> ⚠️ **기본 베이스(위 3멤버)에는 CRUD 메서드가 없다.** `this.query(...)`(SQL) 또는
|
|
117
|
+
> `this.db.collection(...)`(mongo)으로 **직접** 도메인 메서드를 작성한다(아래 예시).
|
|
118
|
+
>
|
|
119
|
+
> 단, **`static schema`(§5-1 빌더) 를 선언하면** 스키마가 보증하는 **공통 CRUD 13종이 opt-in 으로
|
|
120
|
+
> 활성화**된다(`findOne`/`insertOne`/`updateOne`/`upsert` 등 — SQL 어댑터 전용, ADR-212). 보일러플레이트
|
|
121
|
+
> CRUD 는 그걸 쓰고, 비교·`LIKE`·`OR`·`JOIN`·집계 같은 복잡 쿼리는 여전히 `this.query` 로 쓴다. 둘은 공존한다.
|
|
122
|
+
> → **[§2-1 공통 CRUD](#2-1-공통-crud-static-schema-opt-in-adr-212)**.
|
|
123
|
+
|
|
124
|
+
### 스키마 정의 — 두 static 필드
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
export class User extends MegaModel {
|
|
128
|
+
static adapter = 'primary' // mega.config.js 의 services.databases 키(globalKey, ADR-061)
|
|
129
|
+
static table = 'users' // SQL 테이블 또는 Mongo 컬렉션 식별자(ADR-081)
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- `adapter` 가 비었으면 접근 시 `model.adapter_required`, `table` 이 비었으면 `model.table_required` throw.
|
|
134
|
+
- `adapter` 가 등록 안 된 키면 `model.adapter_not_found`(검증은 접근 시점 lazy).
|
|
135
|
+
|
|
136
|
+
### `MegaModel.adapter` 는 globalKey 직접 (ctx.db 별명과 다름)
|
|
137
|
+
|
|
138
|
+
| | 가리키는 것 | 경로 |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `MegaModel.adapter = 'primary'` | **globalKey 직접** | 모델은 요청 ctx 없이 static 으로 평가되므로 전역 매니저에서 바로 잡는다. |
|
|
141
|
+
| `ctx.db('db')` | **앱 별명** | 별명 → globalKey 변환 후 같은 매니저를 본다. |
|
|
142
|
+
|
|
143
|
+
둘 다 종착지는 **동일한 전역 공유 어댑터 인스턴스**다. 그래서 `User`(adapter='primary')와
|
|
144
|
+
`ctx.db('db')`(별명 db → primary)는 같은 연결 풀을 쓴다.
|
|
145
|
+
|
|
146
|
+
### SQL 모델 예시 (postgres, `this.query`)
|
|
147
|
+
|
|
148
|
+
`User` 는 계측 SQL(`this.query`)로 CRUD 를 직접 쓴다. placeholder(`$1`)는 반드시 유지하고 값은
|
|
149
|
+
params 로 바인딩한다(인젝션 방지).
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
// apps/main/models/user.js
|
|
153
|
+
import { MegaModel } from 'mega-framework'
|
|
154
|
+
|
|
155
|
+
export class User extends MegaModel {
|
|
156
|
+
static adapter = 'primary'
|
|
157
|
+
static table = 'users'
|
|
158
|
+
|
|
159
|
+
/** @returns {Promise<object[]>} */
|
|
160
|
+
static async list() {
|
|
161
|
+
const { rows } = await this.query('SELECT id, name, email, created_at FROM users ORDER BY id ASC')
|
|
162
|
+
return rows
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** @param {number} id */
|
|
166
|
+
static async findById(id) {
|
|
167
|
+
const { rows } = await this.query('SELECT id, name, email, created_at FROM users WHERE id = $1', [id])
|
|
168
|
+
return rows[0] ?? null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @param {{ name: string, email: string }} input */
|
|
172
|
+
static async create({ name, email }) {
|
|
173
|
+
const { rows } = await this.query(
|
|
174
|
+
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email, created_at',
|
|
175
|
+
[name, email],
|
|
176
|
+
)
|
|
177
|
+
return rows[0]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {number} id @returns {Promise<boolean>} */
|
|
181
|
+
static async remove(id) {
|
|
182
|
+
const { rowCount } = await this.query('DELETE FROM users WHERE id = $1', [id])
|
|
183
|
+
return rowCount > 0
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`this.query` 는 pg 의 `QueryResult`(`{ rows, rowCount, ... }`)를 그대로 돌려준다(ADR-009).
|
|
189
|
+
|
|
190
|
+
### Document DB 모델 예시 (mongo, `this.db`)
|
|
191
|
+
|
|
192
|
+
mongo 는 SQL 이 아니므로 `this.query` 가 아니라 native 핸들 `this.db`(MongoClient `Db`)로 도큐먼트
|
|
193
|
+
API 를 직접 쓴다.
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
// apps/main/models/note.js
|
|
197
|
+
import { randomUUID } from 'node:crypto'
|
|
198
|
+
import { MegaModel } from 'mega-framework'
|
|
199
|
+
|
|
200
|
+
export class Note extends MegaModel {
|
|
201
|
+
static adapter = 'mongo'
|
|
202
|
+
static table = 'notes'
|
|
203
|
+
|
|
204
|
+
/** 내부 _id 를 빼고 도메인 필드만 노출(§7 함정). */
|
|
205
|
+
static #projection = { projection: { _id: 0 } }
|
|
206
|
+
|
|
207
|
+
/** @returns {import('mongodb').Collection} */
|
|
208
|
+
static get collection() {
|
|
209
|
+
return this.db.collection(this.table) // this.db = MongoClient Db 핸들
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
static async list() {
|
|
213
|
+
return this.collection.find({}, Note.#projection).sort({ created_at: -1 }).toArray()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
static async create({ title, body }) {
|
|
217
|
+
const doc = { id: randomUUID(), title, body, created_at: new Date().toISOString() }
|
|
218
|
+
await this.collection.insertOne({ ...doc })
|
|
219
|
+
return doc
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
static async remove(id) {
|
|
223
|
+
const { deletedCount } = await this.collection.deleteOne({ id })
|
|
224
|
+
return deletedCount > 0
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### 트랜잭션 — `withTransaction` (ADR-108, 어댑터별 인자)
|
|
230
|
+
|
|
231
|
+
자동 request-scoped 트랜잭션은 **없다**(ADR-010). 항상 명시적으로 `withTransaction` 으로 경계를 긋는다.
|
|
232
|
+
|
|
233
|
+
> ⚠️ **격리수준 디폴트는 driver 마다 다르다**(ADR-216): `isolation` 미지정 시 postgres 는
|
|
234
|
+
> READ COMMITTED, mariadb(InnoDB)는 REPEATABLE READ 로 동작한다 — 같은 코드가 driver 에 따라
|
|
235
|
+
> 다른 동시성 의미(phantom/non-repeatable read 허용 여부)를 가진다. driver 간 이식성이 필요한
|
|
236
|
+
> 트랜잭션은 `withTransaction(fn, { isolation: 'repeatable read' })` 처럼 명시하라(ADR-190).
|
|
237
|
+
`fn` 이 받는 인자는 **어댑터마다 다르다**(어댑터가 인자 형태의 정본).
|
|
238
|
+
|
|
239
|
+
| 어댑터 | `fn` 인자 | nested 정책 |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| postgres | `(client)` — pg PoolClient | SAVEPOINT(부분 롤백 지원) |
|
|
242
|
+
| mariadb | `(conn)` — mariadb PoolConnection | SAVEPOINT |
|
|
243
|
+
| sqlite | `(db)` | 재진입 거부(`adapter.nested_transaction_unsupported`) |
|
|
244
|
+
| mongo | `(db, session)` | 재진입 거부 |
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
// SQL — fn 인자(client)로 쿼리해야 한 트랜잭션에 묶인다
|
|
248
|
+
await User.withTransaction(async (client) => {
|
|
249
|
+
await client.query('UPDATE accounts SET balance = balance - 100 WHERE id = $1', [from])
|
|
250
|
+
await client.query('UPDATE accounts SET balance = balance + 100 WHERE id = $1', [to])
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// mongo — 도큐먼트 연산에 { session } 을 넘겨야 트랜잭션에 묶인다
|
|
254
|
+
await User.withTransaction(async (db, session) => {
|
|
255
|
+
await db.collection('users').insertOne({ name: 'kim' }, { session })
|
|
256
|
+
await db.collection('logs').insertOne({ event: 'signup' }, { session })
|
|
257
|
+
})
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
성공 시 commit, `fn` 이 throw 하면 rollback(또는 abort) 후 원본 에러 re-throw.
|
|
261
|
+
|
|
262
|
+
- **트랜잭션 안에서 `Model.query(sql, params)`** 를 부르면 어댑터가 같은 connection 으로 라우팅해 격리가
|
|
263
|
+
유지되고 span 도 트랜잭션 span 의 자식이 된다(postgres/maria 는 AsyncLocalStorage 추적).
|
|
264
|
+
- **`this.db.query`(native)** 는 트랜잭션 **밖** 글로벌 핸들을 쓴다 — 격리가 깨지니 트랜잭션 안에선
|
|
265
|
+
`fn` 인자 또는 계측 `Model.query` 를 쓸 것.
|
|
266
|
+
- **단일 트랜잭션 내부에서 sibling nested 를 `Promise.all` 로 병렬** 실행하는 건 미지원이다. nested 는
|
|
267
|
+
순차(await 로 하나씩) 호출할 것 — 같은 connection 위 SAVEPOINT 이름이 depth 로만 결정돼 충돌한다.
|
|
268
|
+
|
|
269
|
+
### 어댑터별 차이 요약
|
|
270
|
+
|
|
271
|
+
- **SQL(postgres/maria/sqlite)**: 스키마는 마이그레이션 DDL 로 만든다(§5). 모델은 `this.query`(계측) 사용.
|
|
272
|
+
- **Document(mongo)**: 스키마리스. `this.db.collection(...)` 도큐먼트 API. `this.query` 는 미지원.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## 2-1. 공통 CRUD (`static schema` opt-in, ADR-212)
|
|
277
|
+
|
|
278
|
+
`static schema`(§5-1 빌더)를 선언한 모델에는 **스키마가 보증하는 공통 CRUD 13종**이 자동으로 붙는다.
|
|
279
|
+
앱은 dialect 를 고르지 않는다 — 모델이 잡은 어댑터의 driver(postgres/maria/sqlite)를 보고 내부에서
|
|
280
|
+
SQL 을 자동 생성한다.
|
|
281
|
+
|
|
282
|
+
- **opt-in**: `static schema` **있을 때만** 활성. **없으면 영향 0** — 기존 raw 모델은 그대로다(호출 시
|
|
283
|
+
`model.crud_requires_schema`). raw `this.query`/`this.db` 는 **항상 공존**한다(CRUD 는 추가 표면일 뿐).
|
|
284
|
+
- **mongo 지원(ADR-212 P3)**: SQL(postgres/maria/sqlite)·mongo 모두 같은 표면. driver=mongodb 면 내부에서
|
|
285
|
+
**document 경로**(`collection().find/insertOne/...`)로 디스패치된다 — filter→쿼리도큐먼트(배열→`$in`),
|
|
286
|
+
patch→`$set`, select→projection(`_id` 기본 숨김), upsert→`updateOne({upsert:true})`. PK 는 mongo
|
|
287
|
+
네이티브 `_id`(스키마 `_id: t.objectId().primary()`). **단 SQL 과 두 가지 차이**: ① mongo CRUD 는
|
|
288
|
+
`withTransaction` 의 session 에 **자동 참여하지 않는다**(트랜잭션 도큐먼트 연산은 raw `collection.op(doc, { session })`),
|
|
289
|
+
② `updateOne`/`deleteOne` "정확히 하나" 는 사전 `countDocuments` 로 판정(비원자 — 데모 수준).
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
// static schema 선언 → 컬럼·PK·unique 가 CRUD 화이트리스트가 된다(§5-1 과 동일 선언, 마이그레이션과 공유)
|
|
293
|
+
export class UserModel extends MegaModel {
|
|
294
|
+
static adapter = 'primary'
|
|
295
|
+
static table = 'users'
|
|
296
|
+
static schema = (t) => ({
|
|
297
|
+
id: t.serial().primary(),
|
|
298
|
+
email: t.varchar(200).notNull().unique(),
|
|
299
|
+
name: t.varchar(120),
|
|
300
|
+
age: t.integer(),
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await UserModel.insertOne({ email: 'a@x.com', name: 'Ada' }) // → 새 id
|
|
305
|
+
const u = await UserModel.findOne({ email: 'a@x.com' }) // → record | null
|
|
306
|
+
await UserModel.updateOne({ id: u.id }, { name: 'Ada L.' }) // → 1
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### 메서드 13종
|
|
310
|
+
|
|
311
|
+
> 규약: `filter`/`data`/`patch`/`select`/`orderBy` 의 **컬럼 키는 `static schema` 에 선언된 컬럼만**(밖이면
|
|
312
|
+
> `model.unknown_column`). 값은 **전부 파라미터 바인딩**. 반환은 아래 **정규화된 계약**.
|
|
313
|
+
|
|
314
|
+
| 메서드 | 시그니처 | 반환 | 예시 |
|
|
315
|
+
|---|---|---|---|
|
|
316
|
+
| `findOne` | `findOne(filter, opts?)` | `record \| null` | `await M.findOne({ email })` |
|
|
317
|
+
| `findMany` | `findMany(filter?, opts?)` | `record[]` | `await M.findMany({ age: 20 }, { orderBy: 'id', limit: 50 })` |
|
|
318
|
+
| `findById` | `findById(id, opts?)` | `record \| null` | `await M.findById(7, { select: ['id', 'name'] })` |
|
|
319
|
+
| `count` | `count(filter?)` | `number` | `await M.count({ age: 20 })` |
|
|
320
|
+
| `exists` | `exists(filter)` | `boolean` | `await M.exists({ email })` |
|
|
321
|
+
| `paginate` | `paginate(filter, opts)` | `{ rows, limit, offset, total? }` | `await M.paginate({}, { limit: 20, offset: 0, withTotal: true })` |
|
|
322
|
+
| `insertOne` | `insertOne(data, opts?)` | 새 `id` (또는 record) | `await M.insertOne({ email, name })` |
|
|
323
|
+
| `insertMany` | `insertMany(rows, opts?)` | `{ count }` (또는 `record[]`) | `await M.insertMany([{ email: 'a' }, { email: 'b' }])` |
|
|
324
|
+
| `updateOne` | `updateOne(filter, patch)` | `number`(0\|1) | `await M.updateOne({ id }, { name })` |
|
|
325
|
+
| `updateMany` | `updateMany(filter, patch, opts?)` | `number` | `await M.updateMany({ age: 20 }, { age: 21 })` |
|
|
326
|
+
| `deleteOne` | `deleteOne(filter)` | `number`(0\|1) | `await M.deleteOne({ id })` |
|
|
327
|
+
| `deleteMany` | `deleteMany(filter, opts?)` | `number` | `await M.deleteMany({ age: 0 })` |
|
|
328
|
+
| `upsert` | `upsert(data, opts)` | `void` (또는 record) | `await M.upsert({ email, name }, { conflict: ['email'] })` |
|
|
329
|
+
|
|
330
|
+
### 핵심 옵션
|
|
331
|
+
|
|
332
|
+
- **`select`**(projection) — `findOne`/`findMany`/`findById`/`paginate`. 지정한 컬럼만 SELECT. 생략 시 전체(`SELECT *`).
|
|
333
|
+
```js
|
|
334
|
+
await M.findMany({ age: 20 }, { select: ['id', 'email'] }) // id, email 만
|
|
335
|
+
```
|
|
336
|
+
- **`orderBy` / `limit` / `offset`** — `findMany`/`paginate`. `orderBy` 는 `'id'`(asc) 또는
|
|
337
|
+
`[{ column: 'age', dir: 'desc' }]`(dir 은 `asc`|`desc` enum). **`findMany` 는 기본 limit 이 없다** — 큰
|
|
338
|
+
테이블은 직접 `limit` 을 줄 것.
|
|
339
|
+
- **`returning`** 플래그 — `insertOne`/`insertMany`/`upsert`. 기본 `false`(성능 우선): `insertOne`→새 id,
|
|
340
|
+
`insertMany`→`{ count }`, `upsert`→`void`. `true` → 삽입/갱신된 **레코드**.
|
|
341
|
+
```js
|
|
342
|
+
const rec = await M.insertOne({ email, name }, { returning: true }) // 전체 record
|
|
343
|
+
```
|
|
344
|
+
내부적으로 postgres/sqlite 는 `RETURNING`, maria 는 insertId 재조회. **maria 의 `insertMany` + `returning:true`
|
|
345
|
+
는 미지원** → `model.bulk_returning_unsupported`(개별 `insertOne` 또는 `returning:false` 를 쓸 것).
|
|
346
|
+
- **`withTotal`** — `paginate({}, { limit, offset, withTotal: true })` 면 `total`(별도 `count` 쿼리)을 같이 준다.
|
|
347
|
+
생략하면 count 쿼리를 아예 안 날린다(opt-in).
|
|
348
|
+
- **`{ all: true }`** — `updateMany`/`deleteMany` 는 **빈 filter 를 거부**한다(전체 변경 사고 방지,
|
|
349
|
+
`model.unbounded_write`). 진짜 전체가 의도면 명시적으로 `{ all: true }` 를 줄 것.
|
|
350
|
+
```js
|
|
351
|
+
await M.deleteMany({}, { all: true }) // 전체 삭제(의도 명시)
|
|
352
|
+
```
|
|
353
|
+
- **배열값 = `IN`** — filter 값에 배열을 주면 `IN (...)`. `null` 은 `IS NULL`. **빈 배열 `[]` 은 매칭 0**(안전).
|
|
354
|
+
```js
|
|
355
|
+
await M.findMany({ id: [1, 2, 3] }) // WHERE id IN (1,2,3)
|
|
356
|
+
await M.findMany({ deleted_at: null }) // WHERE deleted_at IS NULL
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### 경계 — 여기까지만, 나머지는 raw SQL
|
|
360
|
+
|
|
361
|
+
CRUD 는 **ORM/쿼리빌더가 아니다**(ADR-009 정신). filter 는 **등호 AND + `IN`(배열) + `IS NULL`(null)** 까지만.
|
|
362
|
+
|
|
363
|
+
| 하고 싶은 것 | 방법 |
|
|
364
|
+
|---|---|
|
|
365
|
+
| `=`, `IN`, `IS NULL` 조합(AND) | 공통 CRUD |
|
|
366
|
+
| 비교(`>`,`<`,`BETWEEN`)·`LIKE`·`OR`·`JOIN`·서브쿼리·집계(`GROUP BY`) | **`this.query(sql, params)`** (raw, 계측됨) |
|
|
367
|
+
| 메서드 체인(`.where().limit()`) | 없음 — 설계상 미제공 |
|
|
368
|
+
|
|
369
|
+
```js
|
|
370
|
+
// 복잡 쿼리는 그대로 raw — 같은 모델에서 공존
|
|
371
|
+
static async searchByEmail(q) {
|
|
372
|
+
const { rows } = await this.query('SELECT * FROM users WHERE email LIKE $1 LIMIT 20', [q + '%'])
|
|
373
|
+
return rows
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### 안전 · 트랜잭션 · 계측
|
|
378
|
+
|
|
379
|
+
- **식별자**(테이블·컬럼·select·orderBy·conflict)는 `static schema` **화이트리스트에서만** 나오고
|
|
380
|
+
`quoteIdent` 로 인용된다. **값은 100% 파라미터 바인딩** — 문자열 조립이 없어 인젝션 표면이 없다.
|
|
381
|
+
- **`updateOne`/`deleteOne` 는 "정확히 하나"**: filter 가 2건 이상에 매칭되면 **암묵 트랜잭션으로 롤백** 후
|
|
382
|
+
`model.multiple_matches` throw(부분 변경 방지). PK/unique filter 를 쓰거나 `updateMany`/`deleteMany` 를 쓸 것.
|
|
383
|
+
- **트랜잭션 안에서 CRUD** 를 부르면 어댑터가 **같은 connection 으로 라우팅**해 격리가 유지된다(§2 트랜잭션과
|
|
384
|
+
동일 — `withTransaction` 콜백 안에서 `await M.insertOne(...)` 호출).
|
|
385
|
+
```js
|
|
386
|
+
await UserModel.withTransaction(async () => {
|
|
387
|
+
const id = await UserModel.insertOne({ email, name })
|
|
388
|
+
await ProfileModel.insertOne({ user_id: id })
|
|
389
|
+
}) // 둘 다 같은 tx, throw 시 전부 롤백
|
|
390
|
+
```
|
|
391
|
+
- **계측 자동**: 모든 CRUD 는 `this.query` 로 귀결돼 `<driver>.query` span·메트릭에 잡힌다(ADR-138 — 별도 설정 불요).
|
|
392
|
+
- 주요 에러코드: `crud_requires_schema`(schema 없음) · `unknown_column`(화이트리스트 밖) · `invalid_filter`(빈
|
|
393
|
+
filter·`undefined` 값) · `unbounded_write`(빈 filter 전체 쓰기) · `multiple_matches` · `empty_patch`(빈
|
|
394
|
+
update) · `no_primary_key`(findById 무/복합 PK) · `invalid_conflict_target`(upsert conflict 가 PK/unique 아님).
|
|
395
|
+
|
|
396
|
+
> 결정 근거·트레이드오프·미검증 범위는 **ADR-212**(프레임워크 레포의 `docs/09-decisions-and-open-questions.md`) 참조.
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## 3. Service — MegaService + 자동 DI (ADR-148)
|
|
401
|
+
|
|
402
|
+
서비스는 도메인 비즈니스 로직 층이다. 컨트롤러와 모델 사이에서 검증·에러 매핑·합성을 맡는다.
|
|
403
|
+
|
|
404
|
+
### 파일 위치와 이름 규칙
|
|
405
|
+
|
|
406
|
+
`apps/<app>/services/<name>-service.js` 에 두면 부팅 시 자동 로드된다. 파일명에서 DI 이름이
|
|
407
|
+
도출된다 — `-service` 접미사를 떼고 kebab→camelCase:
|
|
408
|
+
|
|
409
|
+
| 파일 | DI 이름 (`ctx.services.<name>`) |
|
|
410
|
+
|---|---|
|
|
411
|
+
| `user-service.js` | `user` |
|
|
412
|
+
| `audit-log-service.js` | `auditLog` |
|
|
413
|
+
| `user.js`(접미사 없음) | `user` |
|
|
414
|
+
|
|
415
|
+
- 한 파일에 **MegaService 서브클래스 하나**만(둘 이상이면 `service.ambiguous`).
|
|
416
|
+
- `default` export 든 **named** export 든 둘 다 인식한다(스캐폴드 템플릿이 `export class XService` named 를
|
|
417
|
+
쓰므로). 단, MegaService 를 상속한 클래스여야 한다.
|
|
418
|
+
- 같은 앱에서 DI 이름이 겹치면 `service.duplicate_name`.
|
|
419
|
+
|
|
420
|
+
### 클래스 작성 — `constructor(ctx)`
|
|
421
|
+
|
|
422
|
+
`MegaService` 를 상속하면 생성자가 `ctx` 를 받아 `this.ctx`/`this.app`/`this.log`/`this.services` 를
|
|
423
|
+
세팅한다. 보통은 생성자를 안 건드리고 메서드만 쓴다.
|
|
424
|
+
|
|
425
|
+
```js
|
|
426
|
+
// apps/main/services/user-service.js
|
|
427
|
+
import { MegaService } from 'mega-framework'
|
|
428
|
+
import { MegaNotFoundError, MegaValidationError, MegaConflictError } from 'mega-framework/errors'
|
|
429
|
+
import { User } from '../models/user.js' // 모델 import 는 서비스에서만 허용(ADR-022)
|
|
430
|
+
|
|
431
|
+
export class UserService extends MegaService {
|
|
432
|
+
async list() {
|
|
433
|
+
this.log.debug?.('user.list') // this.log = ctx.log (요청 로거)
|
|
434
|
+
return User.list()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async get(id) {
|
|
438
|
+
const user = await User.findById(Number(id))
|
|
439
|
+
if (!user) throw new MegaNotFoundError('user.not_found', `User ${id} not found`, { details: { id } })
|
|
440
|
+
return user
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async create(input) {
|
|
444
|
+
const name = typeof input?.name === 'string' ? input.name.trim() : ''
|
|
445
|
+
const email = typeof input?.email === 'string' ? input.email.trim() : ''
|
|
446
|
+
if (!name || !email) {
|
|
447
|
+
throw new MegaValidationError('user.invalid', 'name and email are required', { details: { name: !!name, email: !!email } })
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
return await User.create({ name, email })
|
|
451
|
+
} catch (err) {
|
|
452
|
+
// postgres unique_violation(23505) → 도메인 충돌(409)로 명시 매핑(P4)
|
|
453
|
+
if (err?.code === '23505') {
|
|
454
|
+
throw new MegaConflictError('user.email_taken', `email '${email}' already exists`, { details: { email }, cause: err })
|
|
455
|
+
}
|
|
456
|
+
throw err
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
베이스가 주는 멤버:
|
|
463
|
+
|
|
464
|
+
| 멤버 | 값 |
|
|
465
|
+
|---|---|
|
|
466
|
+
| `this.ctx` | 요청 컨텍스트(req·log·services·db·cache·...) |
|
|
467
|
+
| `this.app` | MegaApp 인스턴스(`opts.app`, 없으면 null) |
|
|
468
|
+
| `this.log` | `ctx.log` (없으면 console fallback) |
|
|
469
|
+
| `this.services` | `ctx.services` (서비스 간 합성용, 없으면 `{}`) |
|
|
470
|
+
|
|
471
|
+
### 요청별 자동 주입 (lazy proxy)
|
|
472
|
+
|
|
473
|
+
컨트롤러·다른 서비스가 `ctx.services.user` 로 접근하면 프레임워크가 **그 요청의 ctx 로** 인스턴스를
|
|
474
|
+
만들어 준다(ADR-148).
|
|
475
|
+
|
|
476
|
+
```js
|
|
477
|
+
// 컨트롤러 — 모델을 직접 import 하지 않고 ctx.services 로 서비스를 받는다(ADR-074 시그니처: req, reply, ctx)
|
|
478
|
+
export class UserController {
|
|
479
|
+
static async index(_req, _reply, ctx) {
|
|
480
|
+
return ctx.services.user.list()
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
static async create(req, reply, ctx) {
|
|
484
|
+
const user = await ctx.services.user.create(req.body)
|
|
485
|
+
reply.code(201) // 핸들러는 도메인 데이터만 반환, envelope 는 프레임워크가 감싼다
|
|
486
|
+
return user
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
작동 방식:
|
|
492
|
+
|
|
493
|
+
- **요청별 스코프** — 서비스가 `ctx.log`/`db`/`session`/`user` 에 바인딩되므로 싱글톤이 될 수 없다.
|
|
494
|
+
첫 접근 시 생성하고 **같은 요청 안에서 한 번만** 만들어 캐시한다.
|
|
495
|
+
- **lazy** — 접근할 때만 인스턴스화한다. 등록만 하고 안 쓰면 안 만든다.
|
|
496
|
+
- **미등록 이름은 즉시 throw**(`service.not_registered`) — 오타가 조용히 `undefined` 로 통과하지 않게
|
|
497
|
+
fail-fast.
|
|
498
|
+
|
|
499
|
+
### 서비스 간 합성 (`this.services`)
|
|
500
|
+
|
|
501
|
+
한 서비스가 다른 서비스를 부를 땐 `this.services.<name>` 을 쓴다. 같은 proxy 를 거쳐 같은 요청
|
|
502
|
+
인스턴스를 공유한다.
|
|
503
|
+
|
|
504
|
+
```js
|
|
505
|
+
export class OrderService extends MegaService {
|
|
506
|
+
async checkout(input) {
|
|
507
|
+
const user = await this.services.user.get(input.userId) // 같은 요청의 UserService 인스턴스
|
|
508
|
+
// ...
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### 순환 의존 가드
|
|
514
|
+
|
|
515
|
+
생성자에서 서로를 만들면(A 생성자 → B → A) `service.circular_dependency` 로 차단된다. **메서드** 레벨
|
|
516
|
+
상호 호출은 정상이다 — 가드는 컨스트럭터 사이클만 잡는다. 즉, 다른 서비스는 생성자가 아니라 메서드
|
|
517
|
+
안에서 호출할 것.
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## 4. Adapter 별 사용법
|
|
522
|
+
|
|
523
|
+
`services.databases.<key>` 한 블록을 어댑터별로 어떻게 채우는지. driver 마다 받는 옵션이 다르다.
|
|
524
|
+
|
|
525
|
+
### 공통 풀 인터페이스 → 드라이버 풀 옵션 매핑 (ADR-109)
|
|
526
|
+
|
|
527
|
+
`pool` 의 공통 키는 어댑터가 드라이버 실제 키로 변환한다. **단위 주의**:
|
|
528
|
+
|
|
529
|
+
| 공통 키 | pg | mariadb | mongodb |
|
|
530
|
+
|---|---|---|---|
|
|
531
|
+
| `min` | min | minimumIdle | minPoolSize |
|
|
532
|
+
| `max` | max | connectionLimit | maxPoolSize |
|
|
533
|
+
| `idleTimeoutMs` (ms) | idleTimeoutMillis (ms) | idleTimeout (**초**, ÷1000) | maxIdleTimeMS (ms) |
|
|
534
|
+
| `acquireTimeoutMs` | connectionTimeoutMillis | acquireTimeout | waitQueueTimeoutMS |
|
|
535
|
+
| `maxLifetimeMs` (ms) | maxLifetimeSeconds (**초**, ÷1000) | **미지원(throw)** | **미지원(throw)** |
|
|
536
|
+
|
|
537
|
+
- 모르는 풀 키 → `adapter.invalid_option`(오타 fail-fast).
|
|
538
|
+
- 드라이버가 못 받는 키(mariadb/mongo 의 `maxLifetimeMs`) 지정 → throw(silent 무시 안 함).
|
|
539
|
+
- `max` 는 양의 정수, 나머지는 음 아닌 정수.
|
|
540
|
+
|
|
541
|
+
### postgres
|
|
542
|
+
|
|
543
|
+
```js
|
|
544
|
+
primary: {
|
|
545
|
+
driver: 'postgres',
|
|
546
|
+
// (a) 연결 — url 또는 discrete (상호 배타)
|
|
547
|
+
url: 'postgres://user:pw@host:5432/db',
|
|
548
|
+
// host: 'localhost', port: 5432, user: 'mega', password: '...', database: 'mega_test',
|
|
549
|
+
// (b) pool
|
|
550
|
+
pool: { min: 0, max: 10, idleTimeoutMs: 10000, acquireTimeoutMs: 0, maxLifetimeMs: 0 },
|
|
551
|
+
// (c) options — pg.Pool passthrough
|
|
552
|
+
options: { ssl: false, statement_timeout: 30000, application_name: 'mega', keepAlive: true },
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
- native 핸들 = pg `Pool`. 트랜잭션은 풀 클라이언트 1개 + manual `BEGIN/COMMIT/ROLLBACK`, nested 는
|
|
557
|
+
`SAVEPOINT`.
|
|
558
|
+
- 비밀번호·url 은 health/stats/에러 details 에 노출 안 된다.
|
|
559
|
+
|
|
560
|
+
### mariadb
|
|
561
|
+
|
|
562
|
+
```js
|
|
563
|
+
primary: {
|
|
564
|
+
driver: 'mariadb',
|
|
565
|
+
url: 'mariadb://user:pw@host:3306/db', // query string 미지원 — 옵션은 options 로
|
|
566
|
+
pool: { min: 0, max: 10, idleTimeoutMs: 1800000, acquireTimeoutMs: 10000 }, // maxLifetimeMs 쓰면 throw
|
|
567
|
+
options: { bigIntStrategy: 'number', charset: 'utf8mb4', multipleStatements: false },
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
- `options.bigIntStrategy`(디폴트 `'number'`): MariaDB BIGINT 를 JS 로 받는 전략.
|
|
572
|
+
- `'number'` → JS Number. ⚠️ 2^53 초과 정수는 정밀도 손실. 큰 ID 엔 부적합.
|
|
573
|
+
- `'bigint'` → JS BigInt(무손실).
|
|
574
|
+
- `'string'` → 문자열(무손실, 직렬화 친화).
|
|
575
|
+
- `pool.maxLifetimeMs` 는 mariadb 미지원 → 지정 시 throw.
|
|
576
|
+
- url 을 주면 어댑터가 discrete 로 파싱해 객체로 구성하므로 **url 의 query string 은 미지원**(throw).
|
|
577
|
+
|
|
578
|
+
### mongodb
|
|
579
|
+
|
|
580
|
+
```js
|
|
581
|
+
mongo: {
|
|
582
|
+
driver: 'mongodb',
|
|
583
|
+
url: 'mongodb://user:pw@host:27017/mega_test?authSource=admin',
|
|
584
|
+
// dbName: 'mega_test', // url path 에 있으면 추출, 명시 지정이 우선 (url 과 충돌 X — 별개 축)
|
|
585
|
+
pool: { min: 0, max: 10, idleTimeoutMs: 60000, acquireTimeoutMs: 10000 }, // maxLifetimeMs 미지원
|
|
586
|
+
options: { authSource: 'admin', replicaSet: 'rs0', tls: true, serverSelectionTimeoutMS: 5000 },
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
- native 핸들 = MongoClient `Db`. 모델은 `this.db.collection(table)` 로 도큐먼트 API 접근.
|
|
591
|
+
- `dbName` 은 connection 과 **별개 축**이라 url 과 충돌하지 않는다(url path 에서 추출 가능, 명시 우선).
|
|
592
|
+
- **트랜잭션은 replica set(또는 mongos) 필요** — standalone 은 미지원(§7 함정).
|
|
593
|
+
|
|
594
|
+
### sqlite
|
|
595
|
+
|
|
596
|
+
```js
|
|
597
|
+
primary: {
|
|
598
|
+
driver: 'sqlite',
|
|
599
|
+
filename: ':memory:', // 필수! ':memory:' = in-memory, 그 외 = 파일 경로 ('file' 아님 — §7)
|
|
600
|
+
readonly: false, // (옵션)
|
|
601
|
+
fileMustExist: false, // (옵션) 파일 없으면 throw
|
|
602
|
+
timeout: 5000, // (옵션) SQLITE_BUSY 대기 ms (양의 정수)
|
|
603
|
+
wal: false, // (옵션) true → journal_mode=WAL
|
|
604
|
+
pragmas: { foreign_keys: 'ON' },// (옵션) connect 후 추가 PRAGMA
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
- **`filename` 이 필수 키**(빈 문자열도 거부). `pool` 인터페이스를 쓰지 않는다(단일 연결).
|
|
609
|
+
- nested 트랜잭션은 **재진입 거부**(`adapter.nested_transaction_unsupported`).
|
|
610
|
+
|
|
611
|
+
### redis (cache)
|
|
612
|
+
|
|
613
|
+
DB 가 아니라 **캐시 도메인**(`services.caches.<key>`)이다. 모델은 redis 를 쓰지 않고, 컨트롤러가
|
|
614
|
+
`ctx.cache('...')` 또는 brute-force/세션 백엔드로 쓴다.
|
|
615
|
+
|
|
616
|
+
```js
|
|
617
|
+
caches: {
|
|
618
|
+
main: {
|
|
619
|
+
driver: 'redis',
|
|
620
|
+
url: 'redis://:pw@host:6379/1', // 또는 discrete (host/port/user/password)
|
|
621
|
+
db: 1, // 논리 DB 번호 (connection 과 별개 축, url path 보다 우선)
|
|
622
|
+
options: { keyPrefix: 'app:', commandTimeout: 5000, tls: {} }, // ioredis passthrough
|
|
623
|
+
},
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
- **`pool` 미지원** — 지정하면 `adapter.invalid_option` throw(Redis 는 단일 멀티플렉싱 연결 모델, ADR-110).
|
|
628
|
+
- `keyPrefix` 는 **top-level 이 아니라 `options` 안**에 둔다(§7 함정).
|
|
629
|
+
- `db`(논리 DB 0~15)는 connection 과 별개 축이라 url 과 충돌 안 한다.
|
|
630
|
+
|
|
631
|
+
### (참고) 그 외 빌트인
|
|
632
|
+
|
|
633
|
+
- `nats` — 버스 도메인(`services.buses`), 잡 큐 JetStream 백엔드(ADR-119).
|
|
634
|
+
- `file` — 파일 기반 어댑터(ADR-082).
|
|
635
|
+
- `redlock` — 분산 락(`services.locks`), 등록된 redis 캐시를 빌려 쓴다(ADR-113).
|
|
636
|
+
|
|
637
|
+
이 셋은 본 문서(Service+Model+DB) 범위 밖이라 이름만 짚는다.
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## 5. mega migrate — 스키마 마이그레이션 (ADR-149)
|
|
642
|
+
|
|
643
|
+
SQL 스키마(테이블·컬럼)는 코드가 아니라 **마이그레이션 파일**로 만들고 바꾼다.
|
|
644
|
+
|
|
645
|
+
### 파일 위치와 이름
|
|
646
|
+
|
|
647
|
+
`apps/<app>/migrations/<14자리타임스탬프>-<kebab>.js` — 예: `20260606000001-create-users.js`.
|
|
648
|
+
|
|
649
|
+
> 형식 검증식은 `^\d{14}-[a-z0-9][a-z0-9-]*\.js$` 다. 타임스탬프와 이름은 **하이픈(`-`)** 으로
|
|
650
|
+
> 잇는다(언더스코어 아님). generator(`mega g migration <name>`)가 prefix 를 만들어 준다.
|
|
651
|
+
|
|
652
|
+
타임스탬프(파일명 prefix) 오름차순 = 적용 순서다. 형식에 안 맞는 파일·`*.test.js` 는 건너뛴다.
|
|
653
|
+
|
|
654
|
+
### up(db) / down(db)
|
|
655
|
+
|
|
656
|
+
각 파일은 `up`(적용)·`down`(롤백) 두 함수를 export 한다. `db` 는 연결된 DB 어댑터(`{ query,
|
|
657
|
+
withTransaction }`)다.
|
|
658
|
+
|
|
659
|
+
```js
|
|
660
|
+
// apps/main/migrations/20260606000001-create-users.js
|
|
661
|
+
export async function up(db) {
|
|
662
|
+
await db.query(`
|
|
663
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
664
|
+
id SERIAL PRIMARY KEY,
|
|
665
|
+
name TEXT NOT NULL,
|
|
666
|
+
email TEXT NOT NULL UNIQUE,
|
|
667
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
668
|
+
)
|
|
669
|
+
`)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export async function down(db) {
|
|
673
|
+
await db.query('DROP TABLE IF EXISTS users')
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
컬럼 추가/제거 예(기존 row 를 안 깨도록 nullable + `IF [NOT] EXISTS`):
|
|
678
|
+
|
|
679
|
+
```js
|
|
680
|
+
// 20260606000002-add-auth-to-users.js
|
|
681
|
+
export async function up(db) {
|
|
682
|
+
await db.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
|
|
683
|
+
await db.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
|
|
684
|
+
}
|
|
685
|
+
export async function down(db) {
|
|
686
|
+
await db.query('ALTER TABLE users DROP COLUMN IF EXISTS last_login_at')
|
|
687
|
+
await db.query('ALTER TABLE users DROP COLUMN IF EXISTS password_hash')
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
`up`/`down` 둘 중 하나라도 함수가 아니면 `migration.invalid` throw.
|
|
692
|
+
|
|
693
|
+
### 트랜잭션 원자성 + 이력 테이블
|
|
694
|
+
|
|
695
|
+
- 어댑터가 `withTransaction` 을 지원하면 **각 마이그레이션의 up + 이력 기록을 한 트랜잭션으로** 묶는다.
|
|
696
|
+
중간 실패 시 그 마이그레이션 전체가 롤백돼 부분 적용을 막는다(postgres DDL-in-tx).
|
|
697
|
+
- 적용 이력은 대상 DB 의 **`mega_migrations`** 테이블(`name`, `applied_at`, `checksum`)이 추적한다
|
|
698
|
+
(idempotent 생성, v1 테이블은 자동 보강 — ADR-190). 이미 적용된 건 다시 안 돌린다. 적용 후 파일을
|
|
699
|
+
수정하면 checksum 불일치로 `migrate:status` 가 `[!] modified` 를 표시한다.
|
|
700
|
+
- 동시 `mega migrate`(멀티 인스턴스 배포 훅)는 driver 별 마이그레이션 락(postgres advisory lock,
|
|
701
|
+
ADR-190)으로 직렬화된다.
|
|
702
|
+
- 파일에 `export const transaction = false` 를 두면 그 마이그레이션만 트랜잭션 래핑을 끈다
|
|
703
|
+
(`CREATE INDEX CONCURRENTLY` 등 트랜잭션 안에서 실행 불가한 문장용 — 부분 실패 시 롤백 없음, ADR-205).
|
|
704
|
+
|
|
705
|
+
### CLI
|
|
706
|
+
|
|
707
|
+
| 명령 | 동작 |
|
|
708
|
+
|---|---|
|
|
709
|
+
| `mega migrate` | pending 마이그레이션을 타임스탬프 순으로 **일괄 적용**(up). |
|
|
710
|
+
| `mega migrate:down` | **마지막 적용 1개**만 롤백(down). (전체가 아님) |
|
|
711
|
+
| `mega migrate:status` | 적용/미적용 목록 출력. |
|
|
712
|
+
|
|
713
|
+
`--db <KEY>` 로 대상 DB(globalKey)를 고른다(여러 개면 모호 → 명시 필요). `--root <DIR>` 로 프로젝트
|
|
714
|
+
루트 지정. 러너는 config 로드 → 플러그인 install → DB connect → 실행 → disconnect 까지 일회성으로 돈다.
|
|
715
|
+
|
|
716
|
+
---
|
|
717
|
+
|
|
718
|
+
## 5-1. mega migrate:generate — 마이그레이션 자동 생성 (ADR-204/205, postgres)
|
|
719
|
+
|
|
720
|
+
모델에 **`static schema`** 를 선언하면 raw SQL 을 손으로 쓰는 대신, 모델 변경에서 마이그레이션
|
|
721
|
+
파일을 **자동 생성**할 수 있다. 흐름은 journal 방식이다 — DB 를 읽지 않고(`introspection X`,
|
|
722
|
+
연결 자체가 불필요) 직전 스냅샷(`.mega/journal/snapshot.json`)과 현재 모델 선언을 diff 한다.
|
|
723
|
+
지원 driver: **postgres / mariadb / sqlite / mongodb** (ADR-204/207/209 — dialect 별 SQL·mongo command 차이는 자동 처리).
|
|
724
|
+
|
|
725
|
+
> 적용은 그대로 `mega migrate` 다 — 자동 생성 파일도 일반 마이그레이션이라 트랜잭션·락·checksum 이
|
|
726
|
+
> 동일하게 적용되고, 손으로 쓴 raw SQL 마이그레이션과 자유롭게 섞여 공존한다(escape hatch).
|
|
727
|
+
|
|
728
|
+
### 스키마 선언 (빌더 체인)
|
|
729
|
+
|
|
730
|
+
```js
|
|
731
|
+
// apps/main/models/user-model.js
|
|
732
|
+
import { MegaModel } from 'mega-framework'
|
|
733
|
+
|
|
734
|
+
export class User extends MegaModel {
|
|
735
|
+
static adapter = 'primary' // services.databases 키 (driver 가 postgres 여야 자동 생성 지원)
|
|
736
|
+
static table = 'users'
|
|
737
|
+
|
|
738
|
+
static schema = (t) => ({
|
|
739
|
+
id: t.serial().primary(),
|
|
740
|
+
email: t.varchar(200).notNull().unique(),
|
|
741
|
+
name: t.varchar(100).notNull(),
|
|
742
|
+
role: t.enum(['admin', 'user']).default('user'), // TEXT + CHECK 로 렌더
|
|
743
|
+
orgId: t.integer().references('Organization', 'id', { onDelete: 'cascade' }),
|
|
744
|
+
createdAt: t.timestamptz().defaultNow(),
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
static indexes = (t) => [
|
|
748
|
+
t.index(['role']), // idx_users_role
|
|
749
|
+
t.index(['email'], { unique: true }), // uniq_users_email
|
|
750
|
+
t.index({ expression: 'lower(email)' }, { name: 'idx_users_email_lower' }),
|
|
751
|
+
]
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
- **옵트인**이다 — `static schema` 가 없는 모델(레거시 raw SQL 운용)은 그냥 무시된다.
|
|
756
|
+
- 같은 `static schema` 선언은 **공통 CRUD([§2-1](#2-1-공통-crud-static-schema-opt-in-adr-212))도 함께 켠다**(SQL 어댑터) — "스키마 1선언 = 마이그레이션 + CRUD".
|
|
757
|
+
- 타입: `serial/bigSerial/integer/bigInteger/smallInteger/real/doublePrecision/decimal(p,s)/varchar(n)/
|
|
758
|
+
text/char(n)/boolean/timestamp/timestamptz/date/time/uuid/json/jsonb/enum(values)/bytea`.
|
|
759
|
+
- 체인: `.primary() .notNull() .unique({name?}) .default(v | {raw}) .defaultNow() .check(expr,{name?})
|
|
760
|
+
.references(model, col, {onDelete?, onUpdate?, name?}) .comment(text)`. 복합 PK 는 `t.primary(['a','b'])`.
|
|
761
|
+
- FK 대상은 **모델 이름**(file-scan 범위 안에 있어야 함 — 없으면 fail-fast). 관계는 FK 까지만 —
|
|
762
|
+
조인 헬퍼/lazy load 같은 ORM 기능은 없다(ADR-009).
|
|
763
|
+
- 제외 규칙: `_` 로 시작하는 파일/폴더, `*.test.js`, `static skip = true`.
|
|
764
|
+
- 식별자는 63byte(postgres 한도)를 넘으면 거부된다 — 합성 이름(`fk_…`)이 걸리면 `{name}` 으로
|
|
765
|
+
짧은 이름을 명시하면 된다.
|
|
766
|
+
|
|
767
|
+
### 명령
|
|
768
|
+
|
|
769
|
+
```
|
|
770
|
+
mega migrate:generate [name] # diff → apps/<app>/migrations/<ts>-<name|자동슬러그>.js 생성
|
|
771
|
+
--dry-run # SQL 만 출력(파일·journal 무변경)
|
|
772
|
+
--check # CI 드리프트 게이트 — 변경 있으면 exit 1 (생성 X)
|
|
773
|
+
--renames "old:new[,old2:new2]" # 비인터랙티브 rename 확정(오타·불일치는 fail-fast)
|
|
774
|
+
--allow-destroy-drops # rename 후보를 의식적으로 drop+add 로 진행(데이터 손실 인지)
|
|
775
|
+
--concurrent # 인덱스 전용 생성을 CONCURRENTLY(no-tx)로 — 대형 테이블용
|
|
776
|
+
--adapter <key> / --app <name> # 특정 adapter 만 / 파일 둘 앱(기본 main)
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
생성된 파일은 **적용 전 반드시 검토**한다 — 파괴적 변경(`-- 경고`)과 위험 캐스트(`-- TODO: USING`)
|
|
780
|
+
주석이 있으면 직접 확정/수정해야 한다. 수정해도 된다(그게 설계다) — 수정본이 적용되면 checksum 이
|
|
781
|
+
그 내용으로 기록된다.
|
|
782
|
+
|
|
783
|
+
### rename 흐름 (데이터 보존)
|
|
784
|
+
|
|
785
|
+
컬럼 이름을 바꾸면 diff 는 "drop + add" 와 구분할 수 없어 **절대 자동 추론하지 않는다**:
|
|
786
|
+
|
|
787
|
+
1. 터미널(TTY)에서는 후보를 보여주고 1쌍씩 확인 → `RENAME COLUMN` 으로 합성(데이터 보존).
|
|
788
|
+
ESC/Ctrl+C 취소는 전체 중단이다(파일·journal 무변경).
|
|
789
|
+
2. CI 등 비인터랙티브에서는 `--renames "old:new"` 로 확정한다. 미지정이면 **fail-fast** —
|
|
790
|
+
모르고 DROP COLUMN(데이터 손실) 마이그레이션이 생기는 일은 없다. 정말 삭제+추가 의도라면
|
|
791
|
+
`--allow-destroy-drops`.
|
|
792
|
+
3. 기본 명명 FK(`fk_<table>_<col>_<ref>`)가 걸린 컬럼을 rename 하면 `ALTER TABLE … RENAME
|
|
793
|
+
CONSTRAINT` 가 자동 동반돼 실 DB 제약 이름이 표준과 어긋나지 않는다.
|
|
794
|
+
|
|
795
|
+
### journal — git 추적·merge·복구
|
|
796
|
+
|
|
797
|
+
- `.mega/journal/snapshot.json` 이 "직전 상태" 정본이고 **git 에 커밋한다**(마이그레이션 파일과
|
|
798
|
+
같은 PR 에). `history/<ts>-<slug>.json` 은 생성 시점 보존본(감사용).
|
|
799
|
+
- **merge 충돌**: 두 PR 이 서로 다른 모델을 바꿨다면 snapshot 충돌은 보통 국소적이다 — 양쪽 변경을
|
|
800
|
+
모두 남기는 쪽으로 해소하고, 의심스러우면 base 쪽으로 되돌린 뒤 `mega migrate:generate` 를 다시
|
|
801
|
+
돌려 snapshot 을 재생성하라(생성된 마이그레이션 파일들이 이미 둘 다 있으면 "변경 없음" 이 정답).
|
|
802
|
+
- 손상 시(`migration.journal_corrupt`) git 이력에서 복원한다. 동시 generate 는 lock 파일로
|
|
803
|
+
차단된다(`migration.generate_locked` — 프로세스가 없는데 반복되면 `.mega/journal/generate.lock`
|
|
804
|
+
을 삭제).
|
|
805
|
+
|
|
806
|
+
### CI 권장 배선
|
|
807
|
+
|
|
808
|
+
```sh
|
|
809
|
+
mega migrate:generate --check # 모델은 바뀌었는데 generate 를 잊으면 PR 이 빨갛게
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
### 자동 생성 범위 밖 (raw 마이그레이션으로)
|
|
813
|
+
|
|
814
|
+
- **SERIAL ↔ INTEGER 전환**(SEQUENCE/DEFAULT 합성 — fail-fast 로 안내됨), 데이터 backfill,
|
|
815
|
+
파티셔닝(`PARTITION BY`), `COMMENT ON TABLE`, dialect 특화 기능 → `mega g migration <name>` 으로
|
|
816
|
+
raw SQL 파일을 직접 작성한다. 같은 러너로 함께 적용된다.
|
|
817
|
+
- 대형 테이블 인덱스: `--concurrent` 는 **인덱스 변경만 담긴 생성**에서만 허용된다(파일 전체가
|
|
818
|
+
no-tx 가 되므로). 모델 변경을 먼저 적용한 뒤 인덱스만 남겨 실행하라.
|
|
819
|
+
- 자동 생성 driver 는 **postgres / mariadb / sqlite / mongodb** 다. dialect 확장은
|
|
820
|
+
`src/core/migration/dialects/README.md` 의 contract 를 따른다.
|
|
821
|
+
- **모델 제거/table 변경 = 테이블(컬렉션) 전체 DROP** 은 컬럼 rename 과 동일하게 게이트된다
|
|
822
|
+
(ADR-210 L-1, 전 dialect 공통) — 파일을 쓰는 generate 는 `--allow-destroy-drops` 없이
|
|
823
|
+
`migration.drop_unconfirmed` 로 fail-fast 한다(--check/dry-run 은 보고·출력 전용이라 무게이트).
|
|
824
|
+
|
|
825
|
+
### dialect 별 차이 요약 (ADR-207/209)
|
|
826
|
+
|
|
827
|
+
- **mariadb**: enum 은 native `ENUM` 타입(값 변경 = `MODIFY COLUMN`, 값 제거는 위배 데이터 시 실패).
|
|
828
|
+
코멘트는 컬럼 정의 인라인. `--concurrent` 는 `ALGORITHM=INPLACE, LOCK=NONE`(온라인 인덱스).
|
|
829
|
+
**DDL 은 암묵 COMMIT** — 다단 변경 마이그레이션의 중간 실패는 부분 적용으로 남는다(러너가 경고).
|
|
830
|
+
생성 SQL 은 MariaDB 가 지원하는 문장 전부 `IF [NOT] EXISTS` 로 idempotent 렌더(ADR-208) —
|
|
831
|
+
실패 원인을 제거한 뒤 **같은 마이그레이션을 그대로 재실행**하면 이미 적용된 문장은 건너뛰고
|
|
832
|
+
잔여분만 적용된다. CHECK 추가처럼 `IF NOT EXISTS` 미지원 문장이 섞여 재실행이 막히면:
|
|
833
|
+
실패 문장부터 수동 적용 후 이력(`mega_migrations`)을 수동 정합.
|
|
834
|
+
표현식/부분(WHERE) 인덱스는 미지원(명시 거부 — virtual column + raw 마이그레이션으로).
|
|
835
|
+
- **sqlite**: 컬럼 drop·타입/제약/FK/PK 변경은 **테이블 재생성(12-step)** 으로 자동 처리된다 —
|
|
836
|
+
생성 파일이 `export const transaction = false` + 자체 BEGIN/COMMIT 으로 돌며 데이터는 컬럼 매핑
|
|
837
|
+
`INSERT…SELECT` 로 보존된다(10k 행 ≈ 수 ms, 행수 비례). 재생성 대상 테이블을 참조하는 **뷰**가
|
|
838
|
+
DB 파일에 있으면 generate 가 fail-fast(`migration.rebuild_view_conflict` — 뷰를 먼저 DROP 하고
|
|
839
|
+
적용 후 재생성), **트리거**는 파일 헤더와 콘솔 경고에 이름을 명시한다 — 재생성 SQL 은 파일 끝에
|
|
840
|
+
직접 추가할 것(ADR-208). UNIQUE 는 제약 대신 UNIQUE INDEX,
|
|
841
|
+
enum 은 TEXT+CHECK, 코멘트는 미지원(주석으로 명시됨). `--concurrent` 미지원(명시 거부).
|
|
842
|
+
동시 `mega migrate` 는 러너가 **적용 직전 + 실패 후 이력 재확인**으로 패자를 skip 경고
|
|
843
|
+
처리한다(오도성 에러로 죽지 않음 — exit 0, 이력은 PK 가 1행 정본 유지, ADR-208). 단, 같은
|
|
844
|
+
no-tx rebuild 를 정확히 동시에 시작하면 패자의 12-step 이 물리적으로 재실행될 수 있다
|
|
845
|
+
(`BEGIN IMMEDIATE` 직렬화 + 자기멱등이라 데이터 무손실 실측) — 배포 훅은 단일 실행을 권장.
|
|
846
|
+
- **mongodb** (ADR-209): 스키마 = collection **validator(`$jsonSchema`)**, 마이그레이션 파일은 SQL
|
|
847
|
+
이 아니라 **mongo command JS 문**(`db` = native `Db` — `db.createCollection/collection/command`).
|
|
848
|
+
컬럼 수준 변경은 전부 **collMod(validator 통짜 교체)** 로 수렴되고, rename 은 collMod 후
|
|
849
|
+
`$rename` updateMany 로 데이터까지 이동(bypassDocumentValidation — SQL 의 RENAME COLUMN 정합).
|
|
850
|
+
**validator 는 기존 도큐먼트를 변환하지 않는다** — 새 required 필드는 생성 파일의 경고 주석을
|
|
851
|
+
따라 backfill(updateMany)을 직접 추가할 것. enum 은 `enum` 키워드, UNIQUE 는 unique 인덱스,
|
|
852
|
+
중첩 도큐먼트는 `t.object({...})`, 배열은 `t.array(t.<type>(), { uniqueItems })`, `_id` 는 자동
|
|
853
|
+
(명시는 `_id: t.objectId().primary()` 만). 부분 인덱스의 `where` 는 **JSON 문자열**
|
|
854
|
+
(partialFilterExpression), `using` 은 text/2dsphere/hashed, 정렬은 `'col:desc'` suffix.
|
|
855
|
+
**명시 거부(escape hatch 안내)**: `.references()`(FK 없음)·`.check()`(cross-field 는 raw 의
|
|
856
|
+
`$expr`)·`.default()`(앱 레벨)·serial(ObjectId 사용)·`_id` 외 PK. collection 검증 옵션은 모델
|
|
857
|
+
`static validation = { level: 'strict'|'moderate', action: 'error'|'warn' }`. mongo DDL 은
|
|
858
|
+
트랜잭션 불가(공식 문서)라 파일은 항상 no-tx — 생성 명령은 **멱등 렌더**(ADR-210: drop 류는
|
|
859
|
+
not-found 무해 통과, createCollection 은 exists 시 validator collMod 수렴)라 **중간 실패 복구는
|
|
860
|
+
원인 제거 후 같은 파일 재실행**이 1차 수단이다(이미 적용된 문장은 건너뜀/수렴). validator 가
|
|
861
|
+
엄격해지는 변경(required 신설·필드 제거·enum/길이 축소·타입 변경)은 collMod 가 조용히 성공하고
|
|
862
|
+
위배 잔존 도큐먼트의 update 에서 늦게(121) 터진다 — 생성 파일의 경고 주석(backfill/`$unset`
|
|
863
|
+
예시)을 적용 전 확인할 것(up/down 모두 산출). 명시 null 을 저장하는 필드는 `.nullable()`
|
|
864
|
+
(`bsonType: ['<type>','null']` 유니온 — 미선언 시 필드 **생략만** 허용되고 명시 null 은 121).
|
|
865
|
+
중첩 필드 인덱스는 dot-path(`t.index(['profile.age'])` — `t.object` shape 에 선언된 path 만).
|
|
866
|
+
부분 인덱스의 연산자는 mongo 허용 목록($eq/$gt/$gte/$lt/$lte/$in/$exists:true/$type/$and/$or)만
|
|
867
|
+
generate 가 통과시킨다($ne 등은 fail-fast).
|
|
868
|
+
동시 `mega migrate` 는 `mega_migrations_lock` 도큐먼트 락으로 직렬화(crash 잔존 lock 은
|
|
869
|
+
에러 안내의 `deleteOne({_id:'lock'})` 수동 정리). collection 이름 변경은 drop+create(경고) —
|
|
870
|
+
데이터 보존이 필요하면 생성 파일을 `db.renameCollection` 1문으로 raw 편집.
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
## 6. 빌트인 어댑터 자기등록 (ADR-150)
|
|
875
|
+
|
|
876
|
+
`mega start`/`migrate`/`worker`/`scheduler` 같은 CLI 부팅은 사용자 코드가 `'mega-framework'` 를 import
|
|
877
|
+
하기 **전에** `buildFromGlobalConfig` 로 driver 를 resolve 한다. 그래서 빌트인 어댑터 8종이 그 시점에
|
|
878
|
+
이미 레지스트리에 등록돼 있어야 한다.
|
|
879
|
+
|
|
880
|
+
이를 보장하려고 부팅 진입 모듈(`src/core/boot.js`)이 어댑터 배럴을 **side-effect import** 한다:
|
|
881
|
+
|
|
882
|
+
```js
|
|
883
|
+
import '../adapters/index.js' // 배럴이 8개 빌트인 어댑터를 import → 각자 Registry.register(...) 자기등록
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
각 어댑터 모듈은 맨 아래에서 자기 driver 를 등록한다(예: `Registry.register('postgres', MegaPostgresAdapter)`).
|
|
887
|
+
빌트인 8종: `postgres / mongodb / mariadb / sqlite / redis / file / nats / redlock`.
|
|
888
|
+
|
|
889
|
+
- **driver 네이티브 모듈(pg 등)은 `_connect()` 의 lazy import 까지 로드 안 된다** — 등록만으로는 안
|
|
890
|
+
불러오므로, 그 어댑터를 안 쓰는 환경이 `pg` 설치를 강제받지 않는다.
|
|
891
|
+
- 3rd party 어댑터는 `config.adapters.register: { cassandra: MegaCassandraAdapter }` 로 주입한다(ADR-044).
|
|
892
|
+
빌트인 이름은 점유 불가(예약어).
|
|
893
|
+
- 같은 driver 를 **다른** 클래스로 재등록하면 `adapter.driver_conflict` throw(silent override 방지).
|
|
894
|
+
|
|
895
|
+
따라서 사용자는 별도 import 없이 `driver: 'postgres'` 라고만 쓰면 된다.
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
## 7. 함정
|
|
900
|
+
|
|
901
|
+
설정에서 자주 틀리는 지점들. 전부 부팅 시 fail-fast 로 잡히지만, 미리 알면 시간을 아낀다.
|
|
902
|
+
|
|
903
|
+
| 함정 | 옳은 것 |
|
|
904
|
+
|---|---|
|
|
905
|
+
| sqlite 에 `file: 'app.db'` | **`filename`** 이 정본 키(ADR-109). `file` 은 무시·미인식. |
|
|
906
|
+
| redis `keyPrefix` 를 top-level 에 | **`options.keyPrefix`** — options 안에 둔다. |
|
|
907
|
+
| mongo 트랜잭션을 standalone 에서 | **replica set(또는 mongos) 필요.** standalone 은 commit 불가. |
|
|
908
|
+
| mongo `_id`(ObjectId) 노출 | 자체 `id`(UUID 등) 필드 권장 + 읽기 projection 으로 `_id: 0`. |
|
|
909
|
+
| "모든 DB 공통 default 블록" 작성 | **per-DB 독립 블록만.** 공통 default 블록은 없다 — 만들지 말 것. |
|
|
910
|
+
| `pool.maxLifetimeMs` 를 mariadb/mongo 에 | 두 드라이버 미지원 → throw. pg 만 지원(초로 변환). |
|
|
911
|
+
| redis 에 `pool` 지정 | Redis 는 풀 모델 아님 → throw. 동시성은 ioredis `options` 로 튜닝. |
|
|
912
|
+
| 트랜잭션 안에서 `this.db.query` | 트랜잭션 **밖** 글로벌 핸들이라 격리 깨짐. `fn` 인자 또는 계측 `Model.query` 사용. |
|
|
913
|
+
| 마이그레이션 파일명 `..._name.js` | 타임스탬프-이름은 **하이픈**(`20260606000001-create-users.js`). |
|
|
914
|
+
| 컨트롤러가 모델 직접 import | 서비스를 거칠 것(ADR-022). `mega/no-direct-model-import` 가 lint 로 차단. |
|
|
915
|
+
|
|
916
|
+
---
|
|
917
|
+
|
|
918
|
+
## 관련 ADR
|
|
919
|
+
|
|
920
|
+
| ADR | 주제 |
|
|
921
|
+
|---|---|
|
|
922
|
+
| ADR-022 | 모델 레이어 — 컨트롤러는 서비스만, 서비스만 모델 import |
|
|
923
|
+
| ADR-108 | withTransaction (어댑터별 인자 형태) |
|
|
924
|
+
| ADR-109 | 어댑터 통합 옵션 구조(per-database, url/discrete/pool/options, envPrefix) |
|
|
925
|
+
| ADR-148 | ctx.services 자동 DI(요청별 lazy proxy, 순환 가드, named export) |
|
|
926
|
+
| ADR-149 | mega migrate(up/down/status, mega_migrations, 트랜잭션 원자성) |
|
|
927
|
+
| ADR-150 | 빌트인 어댑터 자기등록(배럴 side-effect import) |
|
|
928
|
+
| ADR-167 | 라우트 핸들러 시그니처 `(req, reply, ctx)` |
|
|
929
|
+
| ADR-212 | MegaModel 공통 CRUD(`static schema` opt-in, bounded, driver 자동 dialect) — §2-1 |
|