@gaonjs/config 0.22.0 → 0.24.0

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/dist/testkit.d.ts CHANGED
@@ -1,7 +1,18 @@
1
+ import { type MongoConnectionConfig } from '@gaonjs/data';
1
2
  export interface TestDbHandle {
2
3
  /** 등록한 모든 커넥션을 종료하고 배터리를 리셋한다(afterAll 에서 호출). */
3
4
  close(): Promise<void>;
4
5
  }
6
+ /**
7
+ * 문서형(mongodb) 커넥션을 테스트용으로 파생한다 — SQL 의 `<db>_test`(결정 111)와 대칭.
8
+ * 몽고는 CREATE DATABASE 가 없어(첫 쓰기에 생성) 미리 만들 것이 없고, 이름만 갈라도
9
+ * 개발 DB 를 테스트가 건드리지 않는다. `database` 를 채워 돌려주면 adapter-mongo 가
10
+ * dbName 으로 우선 적용한다(결정 364 — url 경로보다 database 가 이긴다).
11
+ *
12
+ * url·database 가 둘 다 없으면 그대로 돌려준다 — openMongoConnection 이 무신호
13
+ * 오배선(기본 DB `test`)을 fail-loud 로 막는 자기 안내를 그대로 내게 둔다(결정 335).
14
+ */
15
+ export declare function deriveMongoTestConfig(cfg: MongoConnectionConfig): MongoConnectionConfig;
5
16
  /**
6
17
  * 테스트용 DB 커넥션 + DB 외 배터리(mail·storage·i18n)를 배선한다(결정 111·136).
7
18
  * 각 커넥션을 `<db>_test` 로 파생해 poolMax:1 로 붙이고, 스토리지·메일을 테스트
package/dist/testkit.js CHANGED
@@ -4,6 +4,10 @@
4
4
  // 만들어 마이그레이션한 그 DB 다. 스캐폴드 `test/setup.ts` 가 부트스트랩으로 쓰고,
5
5
  // 매 테스트 뒤 `truncateAll()` 로 격리한다(§9 실 인프라 · 근거는 결정 111).
6
6
  //
7
+ // 문서형(mongodb) 커넥션도 **등록한다**(`<db>_test` · deriveMongoTestConfig) — 다만
8
+ // SQL 전용 프로비저닝(CREATE DATABASE + 마이그레이션)과 트랜잭션 롤백/truncate 격리는
9
+ // 타지 않는다(§7.3 · 결정 279 — 문서형은 각 테스트가 deleteMany 로 자기 정리).
10
+ //
7
11
  // Redis·NATS 는 배선하지 않는다(웹 전용). mail·storage·i18n 은 serve·work 와 같은
8
12
  // 경로(configureNonDbBatteries)로 배선한다 — 스토리지·메일을 쓰는 잡·서비스가
9
13
  // 테스트에서도 운영과 같은 코드로 돌아야 한다(결정 136 · "wireDomain 안 태워 테스트만
@@ -15,6 +19,30 @@ import { resetStorage } from '@gaonjs/storage';
15
19
  import { resetI18n } from '@gaonjs/i18n';
16
20
  import { connectNats, configureJobs, configureEvents, configureBroadcast, resetJobs, resetEvents, resetBroadcast, outboxServiceTxWrapper, ensureOutboxTable, relayOutboxBatch, setOutboxDrainer, setLockBackend, memoryLockBackend, } from '@gaonjs/async';
17
21
  import { createDb, registerConnection, deriveTestDatabaseConfig, destroyAllConnections, setServiceTxWrapper, setCacheBackend, memoryCacheBackend, getConnection, hasConnection, } from '@gaonjs/data';
22
+ /**
23
+ * 문서형 커넥션의 url 에서 DB 경로를 뽑는다. `new URL` 은 멀티 호스트
24
+ * (`mongodb://h1:1,h2:2/db`)에서 포트 파싱이 깨지므로 스킴·호스트를 문자열로 벗긴다
25
+ * (adapter-mongo 의 urlHasDbPath 와 같은 이유 · 결정 364).
26
+ */
27
+ function mongoDbNameFromUrl(url) {
28
+ const noScheme = url.replace(/^mongodb(\+srv)?:\/\//, '');
29
+ return (noScheme.split('/').slice(1).join('/').split('?')[0] ?? '').trim();
30
+ }
31
+ /**
32
+ * 문서형(mongodb) 커넥션을 테스트용으로 파생한다 — SQL 의 `<db>_test`(결정 111)와 대칭.
33
+ * 몽고는 CREATE DATABASE 가 없어(첫 쓰기에 생성) 미리 만들 것이 없고, 이름만 갈라도
34
+ * 개발 DB 를 테스트가 건드리지 않는다. `database` 를 채워 돌려주면 adapter-mongo 가
35
+ * dbName 으로 우선 적용한다(결정 364 — url 경로보다 database 가 이긴다).
36
+ *
37
+ * url·database 가 둘 다 없으면 그대로 돌려준다 — openMongoConnection 이 무신호
38
+ * 오배선(기본 DB `test`)을 fail-loud 로 막는 자기 안내를 그대로 내게 둔다(결정 335).
39
+ */
40
+ export function deriveMongoTestConfig(cfg) {
41
+ const base = cfg.database ?? (cfg.url ? mongoDbNameFromUrl(cfg.url) : '');
42
+ if (!base)
43
+ return cfg;
44
+ return { ...cfg, database: base.endsWith('_test') ? base : `${base}_test` };
45
+ }
18
46
  /**
19
47
  * 테스트용 DB 커넥션 + DB 외 배터리(mail·storage·i18n)를 배선한다(결정 111·136).
20
48
  * 각 커넥션을 `<db>_test` 로 파생해 poolMax:1 로 붙이고, 스토리지·메일을 테스트
@@ -22,13 +50,24 @@ import { createDb, registerConnection, deriveTestDatabaseConfig, destroyAllConne
22
50
  */
23
51
  export async function connectTestDatabase(cwd = process.cwd()) {
24
52
  const config = await loadGaonConfig(cwd);
53
+ // 문서형(mongodb) 커넥션을 하나라도 열었는지 — close() 에서 몽고 레지스트리를 닫을지 결정한다
54
+ // (wireDomain 의 mongoOpened 와 대칭 · 결정 280).
55
+ let mongoOpened = false;
25
56
  if (config.db) {
26
57
  for (const [key, cfg] of Object.entries(config.db)) {
27
58
  const base = cfg;
28
- // 결정 279(§7.3): 문서형(mongodb)은 SQL 테스트 프로비저닝(<db>_test + CREATE DATABASE)을
29
- // 타지 않는다 — 몽고 테스트 격리는 전용 DB + deleteMany 통합 테스트가 직접 수행한다.
30
- if (base.adapter === 'mongodb')
59
+ // 결정 279(§7.3): 문서형(mongodb)은 SQL 테스트 프로비저닝(CREATE DATABASE + 마이그레이션)을
60
+ // 타지 않는다 — 몽고는 쓰기에 DB 생기고 격리는테스트의 deleteMany 한다.
61
+ // 다만 **등록은 한다** — 이전엔 통째로 continue 라 테스트 안 collection() 호출이
62
+ // "Mongo 커넥션 '<키>' 이(가) 등록되지 않았습니다" 로 깨졌다(serve·work 는 wireDomain 이
63
+ // 여는데 테스트 레인만 빠진 비대칭 · 결정 136 의 "테스트에서만 깨지던" 사각과 동형).
64
+ // 이름은 `<db>_test` 로 갈라 개발 DB 를 테스트가 건드리지 않게 한다(SQL 대칭).
65
+ if (base.adapter === 'mongodb') {
66
+ const { openMongoConnection } = await import('@gaonjs/adapter-mongo');
67
+ await openMongoConnection(key, deriveMongoTestConfig(base));
68
+ mongoOpened = true;
31
69
  continue;
70
+ }
32
71
  const testCfg = deriveTestDatabaseConfig({ ...base, poolMax: 1 });
33
72
  registerConnection(key, createDb(testCfg), base.adapter);
34
73
  }
@@ -74,6 +113,12 @@ export async function connectTestDatabase(cwd = process.cwd()) {
74
113
  return {
75
114
  close: async () => {
76
115
  await destroyAllConnections();
116
+ if (mongoOpened) {
117
+ // 결정 280: 문서형 커넥션이 열렸으면 몽고 레지스트리도 닫는다(SQL 과 대칭 · 배선 역순).
118
+ // 안 닫으면 열린 소켓이 이벤트 루프를 붙잡아 vitest 프로세스가 종료하지 못한다.
119
+ const { closeMongoConnections } = await import('@gaonjs/adapter-mongo');
120
+ await closeMongoConnections();
121
+ }
77
122
  resetStorage();
78
123
  resetI18n();
79
124
  // 결정 249: 락·캐시 백엔드 해제(다음 테스트 파일로 상태 누출 방지 · wireDomain.closeDomain 대칭).
package/dist/types.d.ts CHANGED
@@ -47,6 +47,12 @@ export type StorageDiskConfig = {
47
47
  readonly driver: 'local';
48
48
  readonly root: string;
49
49
  readonly publicUrl?: string;
50
+ /**
51
+ * 결정 401: 공개 서빙 범위. 이 접두사 아래 키만 `publicUrl` 경로로 응답하고
52
+ * 나머지는 404 다(생략 시 'public/'). 로컬 디스크는 한 폴더에 공개·비공개가
53
+ * 섞이므로 접두사가 유일한 경계다 — `''` 로 두면 디스크 전체가 무인증 공개.
54
+ */
55
+ readonly publicPrefix?: string;
50
56
  } | {
51
57
  readonly driver: 's3';
52
58
  readonly bucket: string;
@@ -220,6 +226,11 @@ export interface AppConfig {
220
226
  * security: { cors: { origin: 'https://app.example.com' }, rateLimit: { max: 600 } }
221
227
  * `false` 는 이 앱에서 그 방어를 끈다(명시적으로만 · 규칙 8). 보안 응답 헤더는
222
228
  * 전역 전용(앱 override 없음).
229
+ *
230
+ * 결정 389: override 객체는 전역과 **필드 단위 병합**이다 — `rateLimit: {
231
+ * timeWindow: '10 minutes' }` 처럼 일부만 줘도 `max` 는 전역(없으면 코어 기본)을
232
+ * 상속한다. CORS 는 `origin` 미명시 시 전역/코어 기본(`origin:false`)이 유지된다
233
+ * (fail-closed — 부분 override 로 CORS 가 열리지 않는다).
223
234
  */
224
235
  readonly security?: AppSecurityOptions;
225
236
  }
package/dist/wire.js CHANGED
@@ -184,6 +184,13 @@ export async function configureNonDbBatteries(config, cwd = process.cwd(), opts
184
184
  ` → gaon.config.ts 의 mail 블록에 ${m.user ? 'pass' : 'user'} 를 함께 설정하거나(.env 의 SMTP_USER/SMTP_PASS),\n` +
185
185
  ` → 인증 없는 SMTP(MailPit 등)라면 둘 다 제거하세요.`);
186
186
  }
187
+ // 결정 404: secure(implicit TLS · 465)면 nodemailer 가 requireTLS 를 무시한다 —
188
+ // 연결은 이미 암호화라 위험은 없지만, 켠 설정이 아무 일도 안 하는 걸 모르면
189
+ // "STARTTLS 를 강제했다" 는 오해가 남는다. 부팅 경고로만 알린다(정당한 설정이라 throw 아님).
190
+ if (m.secure && m.requireTls) {
191
+ log.warn('mail.requireTls 는 secure: true 에서 무시됩니다 — secure(465)는 이미 암호화 연결로 시작합니다. ' +
192
+ 'STARTTLS 강제가 목적이면 secure 를 끄고 requireTls 만 켜세요(587).');
193
+ }
187
194
  configureMailer({
188
195
  transport: smtpTransport({
189
196
  host: m.host,
@@ -200,10 +207,7 @@ export async function configureNonDbBatteries(config, cwd = process.cwd(), opts
200
207
  const disks = {};
201
208
  for (const [name, d] of Object.entries(config.storage.disks)) {
202
209
  if (d.driver === 'local') {
203
- // root 프로젝트 상대 경로면 cwd 로 절대화.
204
- const base = d.root.startsWith('/') ? d.root : join(root, d.root);
205
- const root2 = opts.localRootFor ? opts.localRootFor(base) : base;
206
- disks[name] = localDisk({ root: root2, baseUrl: d.publicUrl });
210
+ disks[name] = localDisk({ root: localDiskRoot(d.root, root, opts.localRootFor), baseUrl: d.publicUrl });
207
211
  }
208
212
  else {
209
213
  disks[name] = s3Disk({
@@ -407,6 +411,8 @@ async function assembleApp(config, root, domain, redis, nats) {
407
411
  storageOrigins: storageCspOrigins(config.storage),
408
412
  // 결정 355: 로컬 디스크 공개 경로(`/storage/<key>`)를 프레임웍이 직접 서빙 —
409
413
  // url() 이 만드는 좌표를 아무도 응답하지 않던 조용한 404 를 없앤다.
414
+ // 결정 405: root 계산은 디스크 배선과 **같은 헬퍼**(localDiskRoot)를 쓴다 — serve
415
+ // 경로엔 테스트 격리 훅이 없지만(wireGaon 표면에 없음), 계산이 한 곳이라 갈라지지 않는다.
410
416
  localStorage: localStorageServeTargets(config.storage, root),
411
417
  // 결정 356: 업로드 한도 config 표면(web.uploads) — 이전엔 createApp 옵션만 있고
412
418
  // wireGaon 이 전달하지 않아 정본 경로(gaon.config.ts)로 한도를 올릴 방법이 없었다.
@@ -510,7 +516,7 @@ void redisSessionStore;
510
516
  * 절대 URL(별도 서버/CDN)은 앱이 서빙하지 않는다. 같은 경로에 다른 root 가 겹치면
511
517
  * fail-loud(어느 디스크가 응답할지 비결정 — 조용한 오서빙 방지).
512
518
  */
513
- function localStorageServeTargets(storage, root) {
519
+ function localStorageServeTargets(storage, root, localRootFor) {
514
520
  if (!storage)
515
521
  return [];
516
522
  const out = [];
@@ -520,7 +526,9 @@ function localStorageServeTargets(storage, root) {
520
526
  const urlPrefix = d.publicUrl ?? '/storage';
521
527
  if (!urlPrefix.startsWith('/'))
522
528
  continue;
523
- const abs = d.root.startsWith('/') ? d.root : join(root, d.root);
529
+ // 결정 405: 디스크 배선과 **같은 헬퍼**로 root 를 계산한다 — 테스트 격리
530
+ // (localRootFor)가 한쪽에만 걸리면 업로드 위치와 서빙 위치가 어긋난다.
531
+ const abs = localDiskRoot(d.root, root, localRootFor);
524
532
  const dup = out.find((o) => o.urlPrefix === urlPrefix);
525
533
  if (dup) {
526
534
  if (dup.root !== abs) {
@@ -529,10 +537,19 @@ function localStorageServeTargets(storage, root) {
529
537
  }
530
538
  continue;
531
539
  }
532
- out.push({ urlPrefix, root: abs });
540
+ out.push({ urlPrefix, root: abs, publicPrefix: d.publicPrefix });
533
541
  }
534
542
  return out;
535
543
  }
544
+ /**
545
+ * 로컬 디스크의 실 저장 루트 — 상대 경로는 프로젝트 루트 기준으로 절대화하고,
546
+ * 테스트 격리 훅(localRootFor)을 적용한다. 디스크 배선과 공개 서빙이 이 한 지점을
547
+ * 공유해야 두 좌표가 갈라지지 않는다(결정 405).
548
+ */
549
+ function localDiskRoot(diskRoot, root, localRootFor) {
550
+ const base = diskRoot.startsWith('/') ? diskRoot : join(root, diskRoot);
551
+ return localRootFor ? localRootFor(base) : base;
552
+ }
536
553
  function storageCspOrigins(storage) {
537
554
  if (!storage)
538
555
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/config",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Gaon 루트 설정 로더·자동 배선 (gaon.config.ts)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,14 +30,14 @@
30
30
  ],
31
31
  "dependencies": {
32
32
  "ioredis": "^5.4.1",
33
- "@gaonjs/adapter-mongo": "0.3.0",
33
+ "@gaonjs/adapter-mongo": "0.4.1",
34
34
  "@gaonjs/core": "0.2.4",
35
- "@gaonjs/async": "0.17.0",
36
- "@gaonjs/data": "0.24.0",
37
- "@gaonjs/i18n": "0.2.4",
38
- "@gaonjs/web": "0.27.0",
35
+ "@gaonjs/async": "0.18.0",
36
+ "@gaonjs/i18n": "0.3.0",
37
+ "@gaonjs/mail": "0.5.0",
39
38
  "@gaonjs/storage": "0.1.5",
40
- "@gaonjs/mail": "0.4.0"
39
+ "@gaonjs/data": "0.25.1",
40
+ "@gaonjs/web": "0.29.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "fastify": "^5.0.0"