@gaonjs/cli 0.31.1 → 0.33.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.
Files changed (53) hide show
  1. package/dist/commands/build.d.ts +11 -0
  2. package/dist/commands/build.js +49 -0
  3. package/dist/commands/check.js +41 -0
  4. package/dist/commands/db.d.ts +5 -1
  5. package/dist/commands/db.js +92 -36
  6. package/dist/db/resolve.d.ts +4 -6
  7. package/dist/db/resolve.js +26 -13
  8. package/dist/dev/build.d.ts +34 -0
  9. package/dist/dev/build.js +144 -0
  10. package/dist/dev/frontend-build.d.ts +3 -3
  11. package/dist/dev/frontend-build.js +42 -37
  12. package/dist/doctor/async-offload.d.ts +3 -1
  13. package/dist/doctor/async-offload.js +5 -2
  14. package/dist/doctor/auth-wiring.d.ts +4 -2
  15. package/dist/doctor/auth-wiring.js +13 -6
  16. package/dist/doctor/connections.d.ts +23 -1
  17. package/dist/doctor/connections.js +85 -14
  18. package/dist/doctor/csrf-wiring.d.ts +3 -1
  19. package/dist/doctor/csrf-wiring.js +9 -5
  20. package/dist/doctor/schema-relations.d.ts +7 -0
  21. package/dist/doctor/schema-relations.js +114 -0
  22. package/dist/doctor/seal-security.js +12 -6
  23. package/dist/doctor/source-scan.d.ts +11 -0
  24. package/dist/doctor/source-scan.js +70 -0
  25. package/dist/doctor/types.d.ts +1 -1
  26. package/dist/doctor/types.js +1 -1
  27. package/dist/doctor.d.ts +2 -1
  28. package/dist/doctor.js +8 -2
  29. package/dist/generate.d.ts +1 -1
  30. package/dist/generate.js +55 -13
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +22 -4
  33. package/dist/scaffold/app.js +2 -1
  34. package/dist/templates/auth/Dashboard.vue.tpl +2 -2
  35. package/dist/templates/auth/Login.vue.tpl +2 -2
  36. package/dist/templates/auth/Signup.vue.tpl +2 -2
  37. package/dist/templates/auth/app.config.ts.tpl +3 -3
  38. package/dist/templates/auth/dashboard.controller.ts.tpl +1 -1
  39. package/dist/templates/auth/registration.controller.ts.tpl +3 -3
  40. package/dist/templates/auth/session.controller.ts.tpl +5 -5
  41. package/dist/templates/project/AGENTS.md.tpl +12 -6
  42. package/dist/templates/project/agents/async.md.tpl +26 -5
  43. package/dist/templates/project/agents/data.md.tpl +56 -12
  44. package/dist/templates/project/agents/security.md.tpl +16 -2
  45. package/dist/templates/project/agents/storage.md.tpl +110 -0
  46. package/dist/templates/project/agents/testing.md.tpl +24 -5
  47. package/dist/templates/project/docker-compose.yaml.tpl +7 -5
  48. package/dist/templates/project/gaon.config.ts.tpl +4 -0
  49. package/dist/templates/project/package.json.tpl +1 -1
  50. package/dist/templates/project/test/setup.ts.tpl +10 -7
  51. package/dist/templates/project/vite.config.ts.tpl +6 -5
  52. package/dist/templates/project/vitest.config.ts.tpl +5 -0
  53. package/package.json +7 -7
@@ -340,11 +340,50 @@ const rows = await Post.query()
340
340
  ### 7. 멀티 DB 커넥션 (v0.15 §4.5)
341
341
 
342
342
  - **키 생략 = main** — 기본 경로는 단일 DB 프로젝트와 완전히 같다.
343
- - **커넥션을 가로지르는 `belongsTo` 금지** — doctor
344
- **connections** 검사가 잡는다.
345
- - **`service()` 트랜잭션은 단일 커넥션에서만 원자적** — 다중 커넥션
346
- 접근 doctor 경고.
347
- - 마이그레이션은 커넥션별: `gaon db diff --db legacy`.
343
+ - **커넥션을 가로지르는 `belongsTo`·역방향 관계는 금지** — SQL 조인은
344
+ 커넥션을 못 넘는다. doctor 의 **schema-relations** 검사(결정 134)가
345
+ `cross-connection-belongsTo`·`cross-connection-relation`·존재하지 않는 관계
346
+ 대상을 **에러**로 잡는다(배포 후 raw postgres 에러 대신 `gaon doctor` 에서).
347
+ 커넥션 등록 정합은 **connections** 검사가 본다.
348
+ - **`service()` 트랜잭션은 단일 커넥션에서만 원자적** — Gaon 은 분산
349
+ 트랜잭션을 흉내 내지 않는다(§9 정본).
350
+ - **마이그레이션은 전 커넥션에 걸린다** — `gaon db migrate`(인자 없음)는 등록된
351
+ **모든** 커넥션을 순회 적용한다(결정 139 · One Way — 커넥션 하나를 잊어 빈 채
352
+ 배포하는 사고 방지). 한 커넥션만 좁히려면 `gaon db migrate --db legacy`.
353
+ diff·status·seed 도 같은 정책(생략=전 커넥션 · `--db <키>`=단일). reset(파괴적)만
354
+ 항상 단일이다.
355
+
356
+ #### 두 DB 에 걸친 쓰기 — `afterCommit` 로 잇는다 (정본)
357
+
358
+ 커넥션을 가로지르는 쓰기(예: main 에 주문 저장 → analytics 에 집계 기록)는
359
+ **한 트랜잭션으로 묶을 수 없다**. main 커넥션 트랜잭션을 **먼저 커밋**하고,
360
+ 성공한 뒤에만 `afterCommit` 에서 보조 커넥션에 쓴다 — 실패해도 main 은 이미
361
+ 안전하고, 재시도·보정은 잡/아웃박스로 다룬다.
362
+
363
+ ```ts
364
+ // domain/services/PlaceOrder.ts — main 커밋 성공 뒤에만 analytics 기록
365
+ import { service, afterCommit } from 'gaonjs/service'
366
+ import { getConnection } from 'gaonjs/data'
367
+
368
+ export const placeOrder = service(async (input: { userId: string; total: number }) => {
369
+ const order = await Order.create({ userId: input.userId, total: input.total }) // main 트랜잭션
370
+
371
+ // 커넥션을 가로지르는 쓰기는 트랜잭션 밖 — 커밋 성공 뒤에만.
372
+ afterCommit(async () => {
373
+ await getConnection('analytics')
374
+ .insertInto('order_stats')
375
+ .values({ orderId: order.id, total: input.total })
376
+ .execute()
377
+ })
378
+ return order
379
+ })
380
+ ```
381
+
382
+ - **왜 트랜잭션에 안 넣나** — 두 커넥션에 걸친 원자성은 불가능하다. main 을
383
+ 진실의 원천으로 커밋하고 analytics 는 파생으로 뒤따르게 한다(정합이 중요하면
384
+ 아웃박스/보상 트랜잭션으로 격상).
385
+ - **왜 `afterCommit`** — main 이 롤백되면 analytics 기록도 일어나지 않아야 한다.
386
+ `afterCommit` 은 커밋이 성공한 경우에만 콜백을 돈다(§9 · `agents/async.md`).
348
387
 
349
388
  ### 8. 모델 정의 (`model()`) (`packages/data/src/model.ts:601-622`)
350
389
 
@@ -504,8 +543,9 @@ export const PublishPost = service(async (postId: bigint) => {
504
543
  롤백. 본문 안의 모델 호출은 코드 변경 없이 트랜잭션에 합류한다
505
544
  (AsyncLocalStorage 전파). `{ transaction: false }` 로 해제.
506
545
  - **커넥션** — `{ db: '키' }` (생략 = main). 트랜잭션은 **단일
507
- 커넥션에서만 원자적** (§7) — 다른 키의 조회는 트랜잭션 밖에서
508
- 돌고, doctor **connections** 검사가 다중 커넥션 접근을 경고한다.
546
+ 커넥션에서만 원자적** (§7) — 다른 키에 걸친 쓰기는 트랜잭션 밖에서
547
+ `afterCommit` 으로 잇는다(§7 "두 DB 걸친 쓰기"). 커넥션 등록
548
+ 정합은 doctor **connections**, 관계 경계는 **schema-relations** 가 본다.
509
549
  - **중첩** — 같은 커넥션 키의 서비스가 서비스를 부르면 바깥
510
550
  트랜잭션에 합류한다 (중첩 BEGIN 없음 — 전체가 한 단위).
511
551
  - **`afterCommit(fn)`** — 커밋 성공 뒤에만 실행 (롤백 시 실행 안 됨).
@@ -517,14 +557,18 @@ export const PublishPost = service(async (postId: bigint) => {
517
557
  ### 10. 마이그레이션 — 파일 리플레이 + 스키마 diff (합성형 · 결정 39)
518
558
 
519
559
  ```bash
520
- gaon db diff # 스키마(domain/schema/*.ts) ↔ 실제 DB 차이 미리보기 (적용 X)
521
- gaon db migrate # db/migrations/*.ts replay → 스키마 diff 적용 + _gaon_migrations 이력
560
+ gaon db diff # 스키마(domain/schema/*.ts) ↔ 실제 DB 차이 미리보기 (적용 X · 전 커넥션)
561
+ gaon db migrate # db/migrations/*.ts replay → 스키마 diff 적용 + _gaon_migrations 이력 (전 커넥션)
522
562
  gaon db migrate down # 가장 최근 이력 1건 롤백
523
- gaon db status # 마이그레이션 파일 적용/대기 + 스키마 drift
524
- gaon db reset --yes # 초기화 (dev · production 거부)
525
- gaon db seed # domain/seed.ts 실행
563
+ gaon db migrate --db analytics # 커넥션만 좁혀 적용
564
+ gaon db status # 마이그레이션 파일 적용/대기 + 스키마 drift (전 커넥션)
565
+ gaon db reset --yes # 초기화 (dev · production 거부 · 항상 단일 커넥션 --db)
566
+ gaon db seed # domain/seed.ts 실행 (전 커넥션)
526
567
  ```
527
568
 
569
+ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션을 순회**한다
570
+ (결정 139). `gaon db diff`(내부 `_gaon_*` 테이블은 계획에서 제외 · 결정 138).
571
+
528
572
  **기본은 스키마 우선이다.** `domain/schema/*.ts` 를 고치고 `gaon db migrate`
529
573
  하면 diff 가 차이를 계산해 반영한다. 여기에 **손작성 마이그레이션 파일**이
530
574
  1급으로 합쳐진다(결정 39 · 합성형): `migrate` 는 ① `db/migrations/*.ts` 를
@@ -43,8 +43,14 @@
43
43
  ### 2. 세션·CSRF·JWT
44
44
 
45
45
  - 세션은 앱별 완전 분리 (v0.15 §7 · Fastify 캡슐화 스코프): 쿠키
46
- 이름(`<app>_sid`) · 서명 secret · Redis 키 prefix · 쿠키 path
47
- 단위로 갇힌다.
46
+ 이름(`<app>_sid`) · 서명 secret · Redis 키 prefix 단위로 갇힌다.
47
+ (쿠키 path 는 `/` — 프리픽스·서브도메인 양쪽 접근에서 쿠키가 실리려면
48
+ 정적 path 는 `/` 여야 한다 · 결정 142. 분리는 위 세 축으로 완성된다.)
49
+ - **멀티 앱 인증**: 둘째 앱은 `gaon g app admin` → `gaon g auth --app admin`
50
+ 으로 만든다 — 스캐폴드가 로그인/리다이렉트 URL 에 앱 프리픽스(`/admin/...`)를
51
+ 자동으로 붙이고, 세션 secret 을 **앱별 env** `<APP>_SESSION_SECRET`(예:
52
+ `ADMIN_SESSION_SECRET`)로 분리 배선한다(결정 141 · 앱별 세션 완전 분리).
53
+ 운영 배포 시 그 env 를 web 과 **다르게** 설정할 것.
48
54
  - CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF
49
55
  강제. `api()` 클라이언트는 `<meta name="csrf-token">` 을 자동으로
50
56
  읽어 `X-CSRF-Token` 헤더에 붙인다 (`packages/vue/src/api.ts:184`).
@@ -55,6 +61,14 @@
55
61
  라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
56
62
  `csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
57
63
  - JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
64
+ - **인증(requireAuth) 과 인가(authorize) 는 별개 축** (결정 145):
65
+ - `this.requireAuth()` = **로그인 여부** — 비로그인이면 401(세션 앱은 로그인 페이지 리다이렉트).
66
+ - `this.authorize(condition)` = **권한 여부** — 조건이 거짓이면 **403**. 존재 자체를
67
+ 숨겨야 하면 `this.authorize(condition, { notFound: true })` → 404. 조건은 호출자가
68
+ 계산한다(예: `this.authorize(this.currentUser?.role === 'admin')`) — v1 은 정책 객체·
69
+ 역할 DSL 을 두지 않는다(리치 authz 는 v1.1 백로그).
70
+ - 실시간 채널의 `authorize` 는 **구독 인가 전용**(§realtime) — HTTP 인가는 `this.authorize`.
71
+ - 손으로 403 을 throw 하거나 인가를 `notFound()` 로 우회하지 말 것 — `this.authorize` 가 The One Way.
58
72
 
59
73
  ### 3. 시크릿
60
74
 
@@ -0,0 +1,110 @@
1
+ # agents/storage.md — 파일 스토리지 (`Storage` · 디스크 · presigned)
2
+
3
+ > 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
4
+ > 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·표·예시의 정본은 이 파일이다.
5
+ > 대상 패키지: `@gaonjs/storage` (파사드 import 는 `gaonjs/storage`).
6
+
7
+ ## 정본 규칙
8
+
9
+ ### 1. 하나의 API, 여러 디스크
10
+
11
+ 파일은 `Storage` 파사드로 저장·조회한다 — 로컬(`storage/`)이든 S3 호환
12
+ (Cloudflare R2·MinIO·AWS S3)이든 **같은 코드**가 돈다. 디스크는
13
+ `gaon.config.ts` 의 `storage` 로 선언하고, endpoint 만 바꾸면 dev(MinIO) →
14
+ 운영(R2/S3) 로 옮겨간다.
15
+
16
+ | 표면 | 시그니처 | 비고 |
17
+ |---|---|---|
18
+ | 저장 | `Storage.put(key, body, { contentType? })` | body = Buffer·string·Uint8Array |
19
+ | 조회 | `Storage.get(key): Promise<Buffer \| null>` | 없으면 null |
20
+ | 삭제 | `Storage.delete(key)` | 멱등 |
21
+ | 존재 | `Storage.exists(key): Promise<boolean>` | |
22
+ | URL | `Storage.url(key, { expiresIn? }): Promise<string>` | 공개 버킷/CDN = 공개 URL · 아니면 presigned |
23
+ | 디스크 선택 | `Storage.disk('s3').put(...)` | 기본 디스크 외 다른 디스크로 |
24
+
25
+ - **URL 은 `Storage.url()` 한 곳**이다 — `publicUrl`(공개 버킷·CDN·R2 public)이
26
+ 있으면 `${publicUrl}/${key}`, 없으면 만료 있는 **presigned URL** 을 만든다.
27
+ `expiresIn`(초)로 만료를 조절한다. 존재하지 않는 `Attachment.urlFor` 같은
28
+ 헬퍼를 만들지 말 것 — 표면은 `Storage.url()` 뿐이다.
29
+ - **키는 경로**다(`avatars/${user.id}.png`). 앞 슬래시는 정규화된다.
30
+
31
+ ### 2. 설정 (`gaon.config.ts`)
32
+
33
+ ```ts
34
+ storage: process.env.STORAGE_ENDPOINT
35
+ ? {
36
+ default: 'main',
37
+ disks: {
38
+ main: {
39
+ driver: 's3', // 's3' | 'local'
40
+ bucket: process.env.STORAGE_BUCKET ?? 'myapp',
41
+ endpoint: process.env.STORAGE_ENDPOINT, // MinIO/R2 = 필수 · AWS S3 = 생략
42
+ accessKeyId: process.env.STORAGE_ACCESS_KEY,
43
+ secretAccessKey: process.env.STORAGE_SECRET_KEY,
44
+ // publicUrl: 'https://cdn.example.com', // 있으면 url()이 공개 URL
45
+ },
46
+ },
47
+ }
48
+ : undefined,
49
+ ```
50
+
51
+ - dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
52
+ (결정 132 · zero-config). `cp .env.example .env && gaon dev` → 우회 0.
53
+ - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/uploads' }`.
54
+ - 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
55
+ 만 env 로 바꾼다.
56
+
57
+ ### 3. 업로드 (multipart)
58
+
59
+ 컨트롤러에서 `this.file('필드명')` 으로 업로드 파일을 받아 그대로 저장한다:
60
+
61
+ ```ts
62
+ // apps/web/controllers/profile.ts
63
+ export default controller({
64
+ async updateAvatar() {
65
+ const f = this.file('avatar') // UploadedFile | undefined
66
+ if (!f) return this.back().withErrors({ avatar: '파일이 필요합니다.' })
67
+ const key = `avatars/${this.auth.user!.id}.png`
68
+ await Storage.put(key, f.buffer, { contentType: f.mimetype })
69
+ return this.redirect('/profile')
70
+ },
71
+ })
72
+ ```
73
+
74
+ - **멀티파트 폼의 CSRF 는 `x-csrf-token` 헤더 전용**이다(결정 133 · 구조적).
75
+ `useForm(...).post(url, { headers: { 'x-csrf-token': shared.csrf } })` 로 보낸다 —
76
+ 바디 `_csrf` 는 멀티파트에서 안 걸린다(상세는 `agents/web.md` §3).
77
+
78
+ ### 4. 스토리지 오리진 CSP 자동 배선 (결정 131)
79
+
80
+ `storage` 에 s3 디스크가 있으면 프레임웍이 그 오리진을 **CSP 에 자동 배선**한다 —
81
+ `endpoint` 는 `connect-src`(브라우저 직접 presigned PUT/GET)와 `img-src` 에,
82
+ `publicUrl` 은 `img-src` 에 붙는다. 이미지 표시·직접 업로드가 CSP 로 막히지
83
+ 않으므로 **CSP 를 손으로 넓히지 말 것**. 오리진은 scheme+host+port 만 잡는다.
84
+
85
+ ### 5. 스토리지를 쓰는 잡·테스트
86
+
87
+ - 무거운 처리(썸네일·외부 업로드)는 컨트롤러 인라인 대신 `domain/jobs/` 잡으로
88
+ 뺀다(`agents/async.md` 판단표 · doctor `async-offload` 경고). 잡·워커(`gaon work`)도
89
+ `wireDomain` 으로 스토리지가 배선돼 있어 `Storage.*` 가 그대로 돈다(결정 129).
90
+ - 테스트에서도 `gaon test` 하네스가 스토리지를 배선한다(결정 136) — s3 버킷은
91
+ `<bucket>-test`, 로컬 root 는 `<root>-test` 로 격리된다(`<db>_test` 대칭 ·
92
+ `agents/testing.md`). 스토리지 잡 테스트는 스캐폴드 `test/setup.ts` 그대로 통과한다.
93
+
94
+ ## 알려진 함정
95
+
96
+ - **존재하지 않는 API 를 상상하지 말 것** — 파일 URL 은 `Storage.url()`,
97
+ 저장은 `Storage.put()` 뿐이다. `Attachment.urlFor`·`Storage.signedUrl` 등은 없다.
98
+ - **`Storage.url()` 은 async** 다 — `await` 를 빠뜨리면 `[object Promise]` 가 렌더된다.
99
+ - **버킷 미준비 = `NoSuchBucket`** — dev 는 compose `createbuckets` 가, 운영은
100
+ 인프라가 버킷을 만든다. 프레임웍은 런타임에 버킷을 만들지 않는다(결정 132).
101
+ - **멀티파트 업로드를 일반 폼처럼 `_csrf` 바디 필드로 보내면 403** — 헤더로
102
+ 옮긴다(결정 133).
103
+
104
+ ## 관련 결정 번호
105
+
106
+ - 결정 131 — 스토리지 오리진 CSP 자동 배선(img-src·connect-src).
107
+ - 결정 132 — dev 버킷 zero-config(compose `createbuckets` · 런타임 버킷 생성 안 함).
108
+ - 결정 133 — 멀티파트 CSRF = `x-csrf-token` 헤더 전용(구조적).
109
+ - 결정 129 — `gaon work` 도 `wireDomain` 으로 스토리지·메일 배선(운영 워커).
110
+ - 결정 136 — `gaon test` 하네스가 스토리지·메일을 테스트 격리 값으로 배선.
@@ -92,22 +92,41 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
92
92
  DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test` 와 스캐폴드
93
93
  `test/setup.ts` 가 The One Way 를 제공한다:
94
94
 
95
- - `gaon test` 가 테스트 전용 DB(`<db>_test`)를 만들고 마이그레이션한다.
96
- - `test/setup.ts` 가 그 DB 붙고(`connectTestDatabase`) 매 테스트 뒤
97
- 테이블을 비운다(`truncateAll`) — 새 테스트는 항상 빈 DB 에서 시작한다.
95
+ - `gaon test` 가 각 커넥션의 테스트 전용 DB(`<db>_test`)를 만들고 마이그레이션한다.
96
+ - `test/setup.ts` 가 그 DB 들에 붙고(`connectTestDatabase`) 매 테스트 뒤
97
+ **등록된 모든 커넥션**의 테이블을 비운다(`truncateAllConnections`) — 새 테스트는
98
+ 항상 빈 DB 에서 시작한다.
99
+ - `connectTestDatabase` 는 DB 뿐 아니라 **스토리지·메일·i18n 도 배선**한다(결정 136)
100
+ — serve·work 와 같은 경로(`wireDomain`)라, 스토리지·메일을 쓰는 잡·서비스가
101
+ 테스트에서도 그대로 돈다. 스토리지 s3 버킷은 `<bucket>-test`, 로컬 root 는
102
+ `<root>-test` 로 격리되고(compose `createbuckets` 가 `<PROJECT>-test` 버킷을 만든다),
103
+ 메일은 MailPit(캡처 sink) 그대로다.
104
+ - `connectTestDatabase` 는 **잡·이벤트 전송(NATS)도 배선**한다(결정 143 · `config.nats`
105
+ 있을 때) — 서비스가 `.later()`·`emit()` 하는 코드가 테스트에서도 운영과 똑같이 돈다.
106
+ 수동 `configureJobs` 는 필요 없다. `expectJobProcessed` 는 **자기 nats 만** 임시로
107
+ 쓰고 끝나면 하네스 배선을 복원하므로, 그 nats 를 `close()` 해도 다음 테스트의
108
+ `.later()` 가 깨지지 않는다(결정 143).
98
109
 
99
110
  스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
100
111
 
101
112
  ```ts
102
113
  import { afterAll, afterEach, beforeAll } from 'vitest'
103
- import { connectTestDatabase, truncateAll, type TestDbHandle } from 'gaonjs/testing'
114
+ import { connectTestDatabase, truncateAllConnections, type TestDbHandle } from 'gaonjs/testing'
104
115
 
105
116
  let handle: TestDbHandle
106
117
  beforeAll(async () => { handle = await connectTestDatabase() })
107
- afterEach(async () => { await truncateAll() })
118
+ afterEach(async () => { await truncateAllConnections() }) // 보조 커넥션(§4.5)까지 전부 격리
108
119
  afterAll(async () => { await handle?.close() })
109
120
  ```
110
121
 
122
+ - **`truncateAllConnections()` vs `truncateAll('키')`** — 전자는 등록된 **모든**
123
+ 커넥션을 순회한다(멀티 커넥션 격리 · 결정 137). 특정 커넥션만 비우려면
124
+ `truncateAll('analytics')` 로 좁힌다. `truncateAll()`(인자 생략)은 main 만 비우므로
125
+ 보조 커넥션이 있는 프로젝트는 `truncateAllConnections()` 를 쓴다.
126
+ - **타임아웃** — 실 인프라 왕복(DB·NATS·스토리지)이 기본 전제라 스캐폴드
127
+ `vitest.config.ts` 는 `testTimeout: 15000`(hookTimeout 포함)으로 둔다 — vitest 기본
128
+ 5s 는 `expectJobProcessed`(10s 대기) 같은 e2e 를 그대로 타임아웃 낸다(결정 137).
129
+
111
130
  그러면 테스트는 격리 코드 없이 모델·서비스를 그대로 부른다:
112
131
 
113
132
  ```ts
@@ -80,10 +80,12 @@ services:
80
80
 
81
81
  # 개발 버킷 생성 — this.file() → Storage.put() 이 첫 업로드부터 돌게 한다.
82
82
  # MinIO 서버만 띄우면 버킷이 없어 첫 업로드가 NoSuchBucket 으로 실패한다 —
83
- # minio 헬스 후 내장 mc 로 dev 버킷(.env 의 STORAGE_BUCKET = 기본 {{PROJECT_NAME}})
84
- # 만들고 종료한다(`--ignore-existing` 라 재기동에 멱등). minio 서버 이미지에
85
- # mc 이미 들어 있어 별도 이미지(minio/mc)를 받지 않는다(첫 gaon dev 가 더 빠름).
86
- # 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 관심사) 컨테이너는 dev 전용.
83
+ # minio 헬스 후 내장 mc 로 dev 버킷(.env 의 STORAGE_BUCKET = 기본 {{PROJECT_NAME}})
84
+ # 테스트 버킷({{PROJECT_NAME}}-test)을 만들고 종료한다(`--ignore-existing` 라 재기동에
85
+ # 멱등). 테스트 버킷은 `gaon test` 하네스가 스토리지를 `<bucket>-test` 격리할
86
+ # 쓴다(결정 136 · <db>_test 대칭). minio 서버 이미지에 mc 이미 들어 있어 별도
87
+ # 이미지(minio/mc)를 받지 않는다(첫 gaon dev 가 더 빠름). 운영(R2/S3)은 인프라에서
88
+ # 버킷을 사전 생성한다(앱 밖 관심사) — 이 컨테이너는 dev·테스트 전용.
87
89
  createbuckets:
88
90
  image: minio/minio:latest
89
91
  depends_on:
@@ -92,6 +94,6 @@ services:
92
94
  entrypoint: >
93
95
  /bin/sh -c "
94
96
  mc alias set local http://minio:9000 {{PROJECT_NAME}} {{PROJECT_NAME}}_secret &&
95
- mc mb --ignore-existing local/{{PROJECT_NAME}}
97
+ mc mb --ignore-existing local/{{PROJECT_NAME}} local/{{PROJECT_NAME}}-test
96
98
  "
97
99
  restart: "no"
@@ -4,6 +4,10 @@ import { defineConfig } from 'gaonjs/config'
4
4
 
5
5
  export default defineConfig({
6
6
  // DB — main 커넥션. 스키마에서 { db: '키' } 로 다른 커넥션에 붙일 수 있다(§4.5).
7
+ // env 미설정이면 배터리를 배선하지 않는다(redis·nats·storage 와 동형). 삼항이어도
8
+ // doctor 의 커넥션·관계 검사가 참 분기의 키(main)를 정적으로 읽어 §4.5 가드레일이
9
+ // 작동한다(결정 135). 커넥션을 늘리려면 참 분기에 키를 더 추가한다:
10
+ // analytics: { adapter: 'postgres', url: process.env.ANALYTICS_URL ?? '' }.
7
11
  db: process.env.DATABASE_URL
8
12
  ? {
9
13
  main: {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "dev": "gaon dev",
12
- "build": "gaon gen && vite build",
12
+ "build": "gaon build",
13
13
  "serve": "gaon serve",
14
14
  "work": "gaon work",
15
15
  "hub": "gaon hub",
@@ -1,13 +1,16 @@
1
- // test/setup.ts — 테스트 격리 부트스트랩 (§9 실 인프라 · 결정 111).
1
+ // test/setup.ts — 테스트 격리 부트스트랩 (§9 실 인프라 · 결정 111·137).
2
2
  //
3
- // `gaon test` 가 테스트 전용 DB(<db>_test)를 만들고 마이그레이션한 뒤 vitest 를
4
- // 돌린다. 이 파일이 그 DB 붙고(connectTestDatabase), 테스트 뒤 전 테이블을
5
- // 비운다(truncateAll). 트랜잭션 롤백이 아니라 truncate 이유: service() 는 실제
6
- // COMMIT 해서 바깥 트랜잭션으로 되돌릴 없다(결정 111 · agents/testing.md).
3
+ // `gaon test` 가 각 커넥션의 테스트 전용 DB(<db>_test)를 만들고 마이그레이션한 뒤
4
+ // vitest 를 돌린다. 이 파일이 그 DB 들에 붙고(connectTestDatabase 스토리지·메일도
5
+ // 테스트 격리 값으로 배선), 테스트 등록된 **모든** 커넥션의 테이블을 비운다
6
+ // (truncateAllConnections). 트랜잭션 롤백이 아니라 truncate 이유: service()
7
+ // 실제 COMMIT 을 해서 바깥 트랜잭션으로 되돌릴 수 없다(결정 111 · agents/testing.md).
8
+ // truncateAll(main 만)이 아니라 truncateAllConnections 인 이유: 보조 커넥션(§4.5)이
9
+ // 비워지지 않으면 상태가 다음 테스트로 새어 조용한 거짓 실패가 난다(결정 137).
7
10
  //
8
11
  // 목업·인메모리 금지(§9) — 실 DB 로만 검증한다.
9
12
  import { afterAll, afterEach, beforeAll } from 'vitest'
10
- import { connectTestDatabase, truncateAll, type TestDbHandle } from 'gaonjs/testing'
13
+ import { connectTestDatabase, truncateAllConnections, type TestDbHandle } from 'gaonjs/testing'
11
14
 
12
15
  let handle: TestDbHandle
13
16
 
@@ -16,7 +19,7 @@ beforeAll(async () => {
16
19
  })
17
20
 
18
21
  afterEach(async () => {
19
- await truncateAll()
22
+ await truncateAllConnections()
20
23
  })
21
24
 
22
25
  afterAll(async () => {
@@ -1,12 +1,13 @@
1
1
  // vite.config.ts — 프론트엔드 빌드/개발 서버 설정 (v0.16 §6.4).
2
2
  //
3
3
  // The One Way: gaon dev 가 Vite 를 middlewareMode 로 붙여 Fastify 한 포트로
4
- // 서빙한다(§CLAUDE.md 6 · 이 파일의 server 옵션은 그때 재정의된다). vite
5
- // build 는 이 파일을 그대로 사용한다.
4
+ // 서빙한다(§CLAUDE.md 6 · 이 파일의 server 옵션은 그때 재정의된다).
6
5
  //
7
- // 앱이 하나 이상이면 각 앱마다 vite.config.ts두는 것이 아니라, 이 루트
8
- // 파일 하나가 root apps/<앱> 으로 잡고 여러 실행된다(gaon dev 가 앱별
9
- // Vite 서버를 띄운다). 관례가 배치.
6
+ // 멀티 앱: 각 앱마다 vite.config 를 두지 않는다. `gaon build`·`gaon dev` 가 이 루트
7
+ // 파일 하나를 **configFile 명시**하고(vue 플러그인·@shared alias 보장 · 결정 146),
8
+ // 앱마다 root=apps/<앱>·outDir=dist/<앱>·base=/<앱>/(web 은 '/') 덮어써 순회 빌드한다.
9
+ // 아래 root·build.outDir 는 apps/web 단독 실행(직접 `vite` 호출) 시의 기본값일 뿐,
10
+ // gaon 파이프라인이 앱별로 재정의한다. gaon g app 으로 늘린 앱은 자동 편입된다.
10
11
  import { fileURLToPath } from 'node:url'
11
12
  import { defineConfig } from 'vite'
12
13
  import vue from '@vitejs/plugin-vue'
@@ -19,5 +19,10 @@ export default defineConfig({
19
19
  ],
20
20
  // 실 DB 를 공유하는 통합 테스트 격리 — 파일 병렬 금지(§9 · 결정 111).
21
21
  fileParallelism: false,
22
+ // 실 인프라(DB·NATS·스토리지) 왕복이 기본 전제라 vitest 기본 5s 는 너무 짧다 —
23
+ // 잡 발행→워커 처리(expectJobProcessed 10s 대기) 같은 e2e 가 그대로 타임아웃 난다.
24
+ // 15s 로 넉넉히 둔다(결정 137 · One Way — 실 인프라 왕복에 맞춘 기본값).
25
+ testTimeout: 15000,
26
+ hookTimeout: 15000,
22
27
  },
23
28
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.31.1",
3
+ "version": "0.33.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.8.0",
31
- "@gaonjs/config": "0.11.0",
32
- "@gaonjs/web": "0.14.0",
33
- "@gaonjs/mail": "0.1.3",
34
- "@gaonjs/data": "0.13.1",
35
- "@gaonjs/core": "0.2.1"
30
+ "@gaonjs/async": "0.9.0",
31
+ "@gaonjs/core": "0.2.1",
32
+ "@gaonjs/config": "0.13.0",
33
+ "@gaonjs/data": "0.15.0",
34
+ "@gaonjs/web": "0.15.0",
35
+ "@gaonjs/mail": "0.1.3"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""