@gaonjs/cli 0.24.0 → 0.26.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/commands/test.js +70 -0
- package/dist/db/migrate.d.ts +3 -0
- package/dist/db/migrate.js +1 -0
- package/dist/db/resolve.d.ts +7 -1
- package/dist/db/resolve.js +5 -1
- package/dist/doctor/page-layout-breakpoint.d.ts +8 -0
- package/dist/doctor/page-layout-breakpoint.js +94 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/ui-kit-wiring.js +11 -7
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +6 -1
- package/dist/index.js +2 -2
- package/dist/templates/auth/Dashboard.vue.tpl +7 -7
- package/dist/templates/auth/Login.vue.tpl +11 -11
- package/dist/templates/auth/Signup.vue.tpl +11 -11
- package/dist/templates/auth/app.ts.tpl +29 -0
- package/dist/templates/auth/server.ts.tpl +14 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/agents/data.md.tpl +39 -2
- package/dist/templates/project/agents/frontend.md.tpl +73 -26
- package/dist/templates/project/agents/testing.md.tpl +53 -6
- package/dist/templates/project/agents/web.md.tpl +42 -3
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +3 -3
- package/dist/templates/project/apps/web/style.css.tpl +62 -41
- package/dist/templates/project/test/setup.ts.tpl +24 -0
- package/dist/templates/project/tsconfig.json.tpl +5 -1
- package/dist/templates/project/vite.config.ts.tpl +16 -0
- package/dist/templates/project/vitest.config.ts.tpl +23 -0
- package/dist/templates/ui-kit/EmptyState.vue.tpl +23 -0
- package/dist/templates/ui-kit/PageHeader.vue.tpl +25 -0
- package/dist/templates/ui-kit/PageShell.vue.tpl +27 -0
- package/dist/templates/ui-kit/Pagination.vue.tpl +60 -0
- package/dist/templates/ui-kit/utils.ts.tpl +1 -1
- package/dist/uikit.d.ts +4 -4
- package/dist/uikit.js +56 -22
- package/package.json +5 -5
|
@@ -149,6 +149,11 @@ shared/composables/useDebounce.ts # 앱 간 공용 컴포저블 (순수 로
|
|
|
149
149
|
`gaon doctor` 의 **shared-composable-purity** 검사가 shared 안에서
|
|
150
150
|
`gaonjs/vue` 의 `api`/`pageProps` import 를 잡는다.
|
|
151
151
|
|
|
152
|
+
**shared 컴포넌트(UI 킷 §8)의 허용/금지도 같은 기준(결정 25·105):** 허용 = 라우트 키와
|
|
153
|
+
무관한 범용 API(`useForm`·`Link`·`router`) · 금지 = 앱 라우트 지식(`api()`·`pageProps`).
|
|
154
|
+
그래서 킷의 `Form` 은 `useForm` 을 써도 되고, `Pagination` 은 라우트를 모른 채
|
|
155
|
+
`v-model:page` 로 현재 페이지만 올려보낸다.
|
|
156
|
+
|
|
152
157
|
### 5. 레이아웃 관례 (errata E-5 §2.3)
|
|
153
158
|
|
|
154
159
|
```
|
|
@@ -195,54 +200,84 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
|
|
|
195
200
|
·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
|
|
196
201
|
소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
|
|
197
202
|
|
|
198
|
-
### 8. UI 킷 (결정 74·75 · shadcn 참조 · 복사-소유)
|
|
203
|
+
### 8. UI 킷 (결정 74·75 · 결정 105 shared 이전 · shadcn 참조 · 복사-소유)
|
|
199
204
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
의존이 아니라 파일이므로 자유롭게 고친다
|
|
203
|
-
(clsx·tailwind-merge·cva·reka-ui 미도입 · `cn` 은
|
|
204
|
-
Transition 자작).
|
|
205
|
+
UI 킷은 프로젝트당 **한 벌**만 `shared/components/ui/` 에 둔다(결정 105 — 결정 75 의
|
|
206
|
+
"앱마다 복사" 개정). `gaon new` 가 심고 `gaon g ui-kit` 로 재보장한다(멱등). 컴포넌트는
|
|
207
|
+
**프로젝트에 복사된 여러분 코드**다 — npm 의존이 아니라 파일이므로 자유롭게 고친다
|
|
208
|
+
(shadcn 방식). 외부 런타임 의존이 없다(clsx·tailwind-merge·cva·reka-ui 미도입 · `cn` 은
|
|
209
|
+
자작 · Dialog/Sheet 는 Teleport+Transition 자작).
|
|
205
210
|
|
|
206
|
-
배치·import
|
|
211
|
+
배치·import — 앱 페이지는 `@shared` alias 로 참조한다:
|
|
207
212
|
|
|
208
213
|
```
|
|
209
|
-
|
|
210
|
-
|
|
214
|
+
shared/lib/utils.ts # cn() — 조건부 클래스 병합
|
|
215
|
+
shared/components/ui/*.vue # 원자 + 블록 (전 앱 공용 순수 UI)
|
|
211
216
|
```
|
|
212
217
|
|
|
213
218
|
```vue
|
|
214
219
|
<script setup lang="ts">
|
|
215
|
-
import Button from '
|
|
216
|
-
import Card from '
|
|
217
|
-
import
|
|
220
|
+
import Button from '@shared/components/ui/Button.vue' // @shared = 프로젝트 shared/
|
|
221
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
222
|
+
import PageShell from '@shared/components/ui/PageShell.vue'
|
|
218
223
|
</script>
|
|
219
224
|
|
|
220
225
|
<template>
|
|
221
|
-
<
|
|
222
|
-
<
|
|
223
|
-
|
|
224
|
-
</CardContent>
|
|
225
|
-
</Card>
|
|
226
|
+
<PageShell>
|
|
227
|
+
<Card><Button variant="default">저장</Button></Card> <!-- variant·size 로 모양 선택 -->
|
|
228
|
+
</PageShell>
|
|
226
229
|
</template>
|
|
227
230
|
```
|
|
228
231
|
|
|
232
|
+
`@shared` 는 `vite.config.ts`(resolve.alias)·`tsconfig.json`(paths)에 배선돼 있다 —
|
|
233
|
+
`../../../shared/...` 같은 깊은 상대경로 대신 alias 하나로 통일한다(결정 105). 앱 전용
|
|
234
|
+
컴포넌트(예 `apps/<앱>/components/PostCard.vue`)는 이 킷을 **조합·확장**해 만든다.
|
|
235
|
+
|
|
236
|
+
**킷 카탈로그 (원자 18 + 블록 4):**
|
|
237
|
+
|
|
238
|
+
| 구분 | 컴포넌트 |
|
|
239
|
+
|---|---|
|
|
240
|
+
| 원자 (18) | Button · Input · Label · Badge · Card · CardHeader · CardTitle · CardDescription · CardContent · CardFooter · Alert · AlertTitle · AlertDescription · Form · FormField · FormMessage · Dialog · Sheet |
|
|
241
|
+
| 블록 (4 · 결정 106) | PageShell · PageHeader · EmptyState · Pagination |
|
|
242
|
+
|
|
243
|
+
- **블록은 성격 중립(결정 106)** — 관리자/프론트를 나누지 않고 전 앱에서 쓴다.
|
|
244
|
+
`PageShell`(최대폭·여백·세로 리듬) · `PageHeader`(제목+설명+액션) · `EmptyState`
|
|
245
|
+
(빈 목록) · `Pagination`(페이지 이동 · `v-model:page`). 이외 블록(DataTable·StatCard·
|
|
246
|
+
Tabs 등)은 아직 만들지 않는다(예약 · 실물 도그푸딩 후).
|
|
247
|
+
- **반응형은 킷 책임(결정 107)** — 폭·여백·열 수 같은 레이아웃 반응형은 `PageShell`
|
|
248
|
+
등 킷 블록이 소유한다. **페이지 코드에 레이아웃 브레이크포인트(`sm:flex-row`·
|
|
249
|
+
`md:grid-cols-2` 등)를 직접 쓰지 않는다** — 킷에 그 표현이 있으면 킷을 쓴다.
|
|
250
|
+
(탈출구: 킷에 없는 표현이면 Tailwind 유틸을 직접 써도 된다 — doctor
|
|
251
|
+
**page-layout-breakpoint** 는 강제가 아닌 **안내 경고**다.)
|
|
229
252
|
- **폼은 UI 킷 Form + gaonjs `useForm`(결정 64)** — `Form` 은 얇은 `<form>` 래퍼로
|
|
230
253
|
`@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
|
|
231
254
|
않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
|
|
232
|
-
오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을
|
|
255
|
+
오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을 표시한다 —
|
|
256
|
+
서버 스키마 검증 실패는 `form.errors.<field>` 로 **자동 반영**된다(결정 109 ·
|
|
257
|
+
컨트롤러가 손으로 다시 렌더하지 않는다 · `agents/web.md` §4.1).
|
|
233
258
|
- **`class` 는 폴스루로 병합** — 단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
|
|
234
259
|
넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
|
|
235
260
|
자동 해소를 하지 않는다 — 오버라이드가 잦으면 tailwind-merge 를 설치해 `cn` 만 교체.
|
|
236
261
|
- **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
|
|
237
262
|
`text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
|
|
238
263
|
`:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
|
|
239
|
-
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
264
|
+
- **shared 킷의 허용/금지 API(결정 25·105)** — 킷은 `shared/` 라 라우트를 몰라야
|
|
265
|
+
한다: 허용 = 라우트 키와 무관한 범용 API(`useForm`·`Link`·`router`) · 금지 =
|
|
266
|
+
앱 라우트 지식(`api()`·`pageProps`). 데이터는 props 로 받는다(예 `Pagination` 은
|
|
267
|
+
`v-model:page` 로 현재 페이지만 올려보내고 실제 이동은 페이지가 정한다).
|
|
268
|
+
- **멀티앱은 앱마다 Tailwind 배선이 따로다(결정 76)** — 킷은 shared 한 벌이지만,
|
|
269
|
+
각 앱이 Tailwind 유틸을 받으려면 그 앱에 `style.css` 배선이 있어야 한다.
|
|
270
|
+
`gaon g app admin` 이 배선을 동봉하고, `gaon g ui-kit --app admin` 은 배선이
|
|
271
|
+
없으면 멱등 보정한다(`--app` 은 이제 킷 위치가 아니라 배선만 정한다).
|
|
272
|
+
`tailwind.config.ts`·`postcss.config.js` 는 프로젝트 루트 공유이고 `content` 는
|
|
273
|
+
`apps/**` 와 `shared/**` 를 함께 훑는다. 앱이 킷을 import 하는데 배선이 없으면
|
|
274
|
+
doctor **ui-kit-wiring** 이 경고한다.
|
|
275
|
+
|
|
276
|
+
**기존 프로젝트 마이그레이션(결정 105 이전 → 이후):** 앱별 사본(`apps/<앱>/components/ui`
|
|
277
|
+
·`apps/<앱>/lib/utils.ts`)이 있으면 `gaon g ui-kit` 를 다시 실행해 `shared/` 에 킷을
|
|
278
|
+
만든 뒤, 앱 사본을 지우고 import 를 `@shared/components/ui/…` 로 바꾼다.
|
|
279
|
+
`tailwind.config.ts` 의 `content` 에 `./shared/**/*.{vue,ts}` 가 있는지도 확인한다
|
|
280
|
+
(스캐폴드 기본값엔 이미 포함).
|
|
246
281
|
|
|
247
282
|
## 정본 예시
|
|
248
283
|
|
|
@@ -297,7 +332,15 @@ async function runSearch(q: string) {
|
|
|
297
332
|
`useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
|
|
298
333
|
URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
|
|
299
334
|
틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
|
|
300
|
-
- **레이아웃을 shared 에 두지 않는다** — 앱별이
|
|
335
|
+
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
|
|
336
|
+
중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
|
|
337
|
+
- **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
|
|
338
|
+
상대경로 대신 `@shared` alias 로 통일한다(결정 105 · `vite.config.ts`·`tsconfig.json`
|
|
339
|
+
배선). 킷을 `apps/<앱>/components/ui` 에 복제하지 않는다.
|
|
340
|
+
- **페이지에 레이아웃 브레이크포인트 직접 사용 지양** — 폭·여백·열 수 반응형은
|
|
341
|
+
킷 블록(`PageShell` 등)이 책임진다(결정 107). `sm:flex-row`·`md:grid-cols-2` 를
|
|
342
|
+
페이지 루트에 직접 쓰면 doctor **page-layout-breakpoint** 가 **안내 경고**를 낸다
|
|
343
|
+
(강제 아님 · 킷에 없는 표현이면 그대로 둬도 된다 — 탈출구 유지).
|
|
301
344
|
- **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
|
|
302
345
|
Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
|
|
303
346
|
(결정 32·46). rename 후 컨트롤러 `this.render('...')` 키도 맞춘다.
|
|
@@ -320,4 +363,8 @@ async function runSearch(q: string) {
|
|
|
320
363
|
| 결정 75 | shadcn 식 UI 킷(`gaon g ui-kit` · 복사-소유 · Vue 3 신작 · 외부 런타임 의존 0) |
|
|
321
364
|
| 결정 76 | 멀티앱 UI 킷 배선 자동화(`g app`·`g ui-kit --app` 이 앱별 Tailwind 배선 동봉·멱등 보정 · doctor ui-kit-wiring) |
|
|
322
365
|
| 결정 96 | 앱 내부 이동 = `Link`(선언적)/`router.visit`(프로그램적) · 내부 경로 일반 `<a>` 금지(풀 리로드) · `Link` 재수출 · doctor internal-anchor |
|
|
366
|
+
| 결정 105 | UI 킷 shared 이전(`shared/components/ui` 프로젝트당 한 벌 · `@shared` alias · 결정 75 개정) |
|
|
367
|
+
| 결정 106 | 최소 4블록(PageShell·PageHeader·EmptyState·Pagination · 성격 중립) |
|
|
368
|
+
| 결정 107 | 반응형은 킷 책임(페이지 레이아웃 브레이크포인트 지양 · doctor page-layout-breakpoint 안내 경고 · 터치 44px·폰트 최소 크기 토큰) |
|
|
369
|
+
| 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
|
|
323
370
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
테스트를 통과해 운영에서 터지는 구멍이었다.
|
|
17
17
|
|
|
18
18
|
- 테스트 전에 compose 로 DB · NATS 를 띄우고, **테스트 전용
|
|
19
|
-
데이터베이스**(
|
|
20
|
-
|
|
21
|
-
- `gaon test`
|
|
19
|
+
데이터베이스**(truncate 격리 · 결정 111) + **테스트 전용 스트림
|
|
20
|
+
프리픽스**를 쓴다.
|
|
21
|
+
- `gaon test` 가 테스트 전용 데이터베이스(`<db>_test`)를 **자동 준비**한다
|
|
22
|
+
— 없으면 만들고(CREATE DATABASE) 마이그레이션까지 적용한 뒤 vitest 를
|
|
23
|
+
돌린다. 스캐폴드 `test/setup.ts` 가 그 DB 에 붙고(`connectTestDatabase`)
|
|
24
|
+
매 테스트 뒤 전 테이블을 비운다(`truncateAll`). 아래 §5 참고.
|
|
22
25
|
- SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
|
|
23
26
|
아니다.
|
|
24
27
|
|
|
@@ -78,11 +81,54 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
|
|
|
78
81
|
- `configureJobs` 는 헬퍼가 대신 해 준다 — 테스트가 부팅 코드를 흉내낼
|
|
79
82
|
필요가 없다.
|
|
80
83
|
|
|
84
|
+
### 5. DB 테스트 격리 — `gaon test` + `test/setup.ts` (결정 111)
|
|
85
|
+
|
|
86
|
+
DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test` 와 스캐폴드
|
|
87
|
+
`test/setup.ts` 가 The One Way 를 제공한다:
|
|
88
|
+
|
|
89
|
+
- `gaon test` 가 테스트 전용 DB(`<db>_test`)를 만들고 마이그레이션한다.
|
|
90
|
+
- `test/setup.ts` 가 그 DB 에 붙고(`connectTestDatabase`) 매 테스트 뒤
|
|
91
|
+
전 테이블을 비운다(`truncateAll`) — 새 테스트는 항상 빈 DB 에서 시작한다.
|
|
92
|
+
|
|
93
|
+
스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { afterAll, afterEach, beforeAll } from 'vitest'
|
|
97
|
+
import { connectTestDatabase, truncateAll, type TestDbHandle } from 'gaonjs/testing'
|
|
98
|
+
|
|
99
|
+
let handle: TestDbHandle
|
|
100
|
+
beforeAll(async () => { handle = await connectTestDatabase() })
|
|
101
|
+
afterEach(async () => { await truncateAll() })
|
|
102
|
+
afterAll(async () => { await handle?.close() })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
그러면 테스트는 격리 코드 없이 모델·서비스를 그대로 부른다:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// test/posts.integration.test.ts
|
|
109
|
+
import { describe, it, expect } from 'vitest'
|
|
110
|
+
import { Post } from '../domain/models/Post.js'
|
|
111
|
+
|
|
112
|
+
describe('Post', () => {
|
|
113
|
+
it('생성·조회', async () => {
|
|
114
|
+
await Post.create({ title: '첫 글', body: '...' })
|
|
115
|
+
expect(await Post.count()).toBe(1n) // 다음 테스트 전 truncateAll 로 0 으로 리셋
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**왜 truncate 인가(트랜잭션 롤백 아님) · 결정 111**: `service()` 는 실제
|
|
121
|
+
BEGIN/COMMIT 을 여는 대상이라, 테스트를 바깥 트랜잭션으로 감싸면 service
|
|
122
|
+
안의 COMMIT 이 그 바깥 트랜잭션을 커밋해 롤백 격리가 조용히 깨진다(실측).
|
|
123
|
+
그래서 격리는 service 가 실제로 커밋하는 운영 경로를 그대로 두고 매 테스트
|
|
124
|
+
뒤 truncate 로 비운다 — service 든 아니든 항상 안전하다.
|
|
125
|
+
|
|
81
126
|
## 정본 예시
|
|
82
127
|
|
|
83
|
-
위 §4 의 `welcomeMail.integration.test.ts` 가 잡 검증의 정본
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
위 §4 의 `welcomeMail.integration.test.ts` 가 잡 검증의 정본 예시이고,
|
|
129
|
+
§5 의 `test/setup.ts` + 모델 테스트가 DB 격리의 정본 예시다. 발행 도착만
|
|
130
|
+
확인하고 싶으면 raw JetStream 구독(§3)도 정합이지만, 기본 경로는
|
|
131
|
+
`expectJobProcessed` 하나다 (The One Way).
|
|
86
132
|
|
|
87
133
|
## 알려진 함정
|
|
88
134
|
|
|
@@ -99,5 +145,6 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
|
|
|
99
145
|
| 결정 | 내용 |
|
|
100
146
|
|---|---|
|
|
101
147
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`gaonjs/testing`) |
|
|
148
|
+
| 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
|
|
102
149
|
| §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
|
|
103
150
|
| 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
|
|
@@ -122,10 +122,21 @@ export default controller({
|
|
|
122
122
|
}
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
스키마 컬럼 중 **일부만** 받으면서 검증을 유지하려면
|
|
126
|
+
`Model.form.pick('a', 'b')` — 지정 컬럼만 검증·통과하는 새 폼을
|
|
127
|
+
돌려준다(안 고른 필수 컬럼은 요구하지 않음 · 원 폼 불변 · 결정 104 ·
|
|
128
|
+
정본 `agents/data.md` §8.1). 폼 변형은 `pick()` 하나뿐이다
|
|
129
|
+
(omit/extend/merge 없음):
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
const data = this.params(Comment.form.pick('postId', 'author', 'body'))
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- **② 애드혹 폼 `this.params({ _row: {} as { ... } })`** — 전용 모델(스키마)이
|
|
136
|
+
없는 입력일 때. 라우트 파라미터·폼 필드를 자유롭게 섞어 타입을
|
|
127
137
|
못박는다. 단 **런타임 스키마 검증은 없다**(타입만 · 컬럼 정의가 없어 coerce
|
|
128
|
-
스킵) — 필요하면 값 검사를 직접 하거나 ①로
|
|
138
|
+
스킵) — 필요하면 값 검사를 직접 하거나 ①로 간다. 스키마가 있는데 필드만
|
|
139
|
+
좁히고 싶은 거라면 ②가 아니라 ①의 `pick()` 이다:
|
|
129
140
|
|
|
130
141
|
```ts
|
|
131
142
|
// routes: r.post('/posts/:id/comments', 'comments#create')
|
|
@@ -167,6 +178,31 @@ router.delete('/session', { headers: { 'x-csrf-token': props.csrf } })
|
|
|
167
178
|
`?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
|
|
168
179
|
엉뚱한 액션(create)에 도달한다.
|
|
169
180
|
|
|
181
|
+
### 4.1 폼 검증 에러 — 스키마 검증만으로 `useForm.errors` 자동 반영 (결정 109)
|
|
182
|
+
|
|
183
|
+
Inertia 폼(`useForm(...).post()`)의 검증 실패는 컨트롤러가 손으로 다시
|
|
184
|
+
렌더하지 않는다 — `this.params(Model.form)` 이 던진 검증 실패를 프레임웍이
|
|
185
|
+
**303 back + 세션 플래시 errors** 로 처리하고, 클라이언트 `useForm` 이 다음
|
|
186
|
+
방문의 `errors` 로 `form.errors.<필드>` 를 **자동으로 채운다**. 입력값은
|
|
187
|
+
`useForm` 이 그대로 보존한다(재제출 방지). 컨트롤러는 성공 경로만 쓴다:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// 컨트롤러 — 검증 실패 분기를 손으로 쓰지 않는다(결정 109).
|
|
191
|
+
async create() {
|
|
192
|
+
const data = this.params(Post.form.pick('title', 'body')) // 실패 시 프레임웍이 303 back
|
|
193
|
+
await Post.create(data)
|
|
194
|
+
return this.redirect('/posts')
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```vue
|
|
199
|
+
<!-- 페이지 — form.errors.<필드> 는 서버 검증 실패 시 자동으로 채워진다. -->
|
|
200
|
+
<input v-model="form.title" />
|
|
201
|
+
<p v-if="form.errors.title">{{ form.errors.title }}</p>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
|
|
205
|
+
|
|
170
206
|
### 5. 비밀번호 해싱 — `hashPassword` · `verifyPassword` (`gaonjs/web`)
|
|
171
207
|
|
|
172
208
|
회원가입·로그인에서 비밀번호를 다룰 때는 **직접 crypto/bcrypt 를 import 하거나
|
|
@@ -333,4 +369,7 @@ export default controller({
|
|
|
333
369
|
| 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
|
|
334
370
|
| 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
|
|
335
371
|
| 결정 95 (W4) | 폼 모양 2종 — 스키마 파생 `Model.form`(검증) vs 애드혹 `{ _row }`(타입만) · 라우트 파라미터는 둘 다 자동 병합 |
|
|
372
|
+
| 결정 104 | `Model.form.pick('a','b')` = 검증되는 부분 폼(결정 95 회부 종결) · 폼 변형은 pick 하나(omit/extend/merge 없음) |
|
|
373
|
+
| 결정 108 | 정적 default 컬럼 빈 입력 채움(coerceParams) · 동적 default 는 DB 위임 |
|
|
374
|
+
| 결정 109 | Inertia 폼 검증 실패 = 303 back + 플래시 errors → `useForm.errors` 자동(§4.1) · JSON/API 는 422 유지 |
|
|
336
375
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
import { pageProps } from 'gaonjs/vue'
|
|
4
4
|
import { useGaonHealth, type HealthDoctor } from '../../composables/useGaonHealth.js'
|
|
5
|
-
import Card from '
|
|
6
|
-
import Badge from '
|
|
5
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
6
|
+
import Badge from '@shared/components/ui/Badge.vue'
|
|
7
7
|
|
|
8
8
|
// home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
|
|
9
9
|
// 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
|
|
@@ -230,7 +230,7 @@ const routeLines = computed<CodeLine[]>(() => {
|
|
|
230
230
|
|
|
231
231
|
<p class="mt-8 text-center text-xs text-muted-foreground">
|
|
232
232
|
<Badge variant="secondary">UI 킷</Badge>
|
|
233
|
-
이 화면·카드·다음 단계는 gaon g ui-kit 로 심은 컴포넌트로 그렸습니다 —
|
|
233
|
+
이 화면·카드·다음 단계는 gaon g ui-kit 로 심은 컴포넌트로 그렸습니다 — shared/components/ui/ 에서 소유·수정하세요.
|
|
234
234
|
</p>
|
|
235
235
|
</div>
|
|
236
236
|
</template>
|
|
@@ -6,53 +6,63 @@
|
|
|
6
6
|
* :root/.dark 의 CSS 변수 = 디자인 토큰(shadcn 관례). UI 킷 컴포넌트는 이
|
|
7
7
|
* 토큰(bg-primary·text-muted-foreground …)만 참조하므로, 브랜드 색을 바꾸려면
|
|
8
8
|
* 여기 한 곳만 고친다. hsl 채널 값으로 두는 이유는 tailwind.config.ts 가
|
|
9
|
-
* hsl(var(--token)) 로 감싸 투명도 유틸(bg-primary/50)까지 동작하게 하기 위함이다.
|
|
9
|
+
* hsl(var(--token)) 로 감싸 투명도 유틸(bg-primary/50)까지 동작하게 하기 위함이다.
|
|
10
|
+
*
|
|
11
|
+
* 결정 107: 토큰(:root·.dark)은 @layer base **밖**에 둔다 — @layer 안의 클래스
|
|
12
|
+
* 선택자 규칙(.dark)은 content 에 'dark' 문자열이 없으면 Tailwind 가 purge 해
|
|
13
|
+
* 다크 모드가 조용히 안 됐다(:root 등 요소 선택자는 남지만 .dark 는 사라짐).
|
|
14
|
+
* 토큰은 CSS 변수만 정의하므로 layer 밖이어도 유틸 속성과 충돌하지 않는다. */
|
|
10
15
|
@tailwind base;
|
|
11
16
|
@tailwind components;
|
|
12
17
|
@tailwind utilities;
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
:root {
|
|
20
|
+
--background: 0 0% 100%;
|
|
21
|
+
--foreground: 240 10% 3.9%;
|
|
22
|
+
--card: 0 0% 100%;
|
|
23
|
+
--card-foreground: 240 10% 3.9%;
|
|
24
|
+
--primary: 240 5.9% 10%;
|
|
25
|
+
--primary-foreground: 0 0% 98%;
|
|
26
|
+
--secondary: 240 4.8% 95.9%;
|
|
27
|
+
--secondary-foreground: 240 5.9% 10%;
|
|
28
|
+
--muted: 240 4.8% 95.9%;
|
|
29
|
+
--muted-foreground: 240 3.8% 46.1%;
|
|
30
|
+
--accent: 240 4.8% 95.9%;
|
|
31
|
+
--accent-foreground: 240 5.9% 10%;
|
|
32
|
+
--destructive: 0 84.2% 60.2%;
|
|
33
|
+
--destructive-foreground: 0 0% 98%;
|
|
34
|
+
--border: 240 5.9% 90%;
|
|
35
|
+
--input: 240 5.9% 90%;
|
|
36
|
+
--ring: 240 5.9% 10%;
|
|
37
|
+
--radius: 0.5rem;
|
|
38
|
+
/* 모바일·반응형 토큰(결정 107). tap-target-min = 터치 최소 타깃(권장 44px) ·
|
|
39
|
+
font-size-min = 입력·본문 최소 글꼴(iOS 가 16px 미만 입력에 포커스하면
|
|
40
|
+
화면을 자동 확대하는 것을 막는 값). 라이트/다크 공통이라 :root 한 곳에 둔다. */
|
|
41
|
+
--tap-target-min: 44px;
|
|
42
|
+
--font-size-min: 16px;
|
|
43
|
+
}
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
.dark {
|
|
46
|
+
--background: 240 10% 3.9%;
|
|
47
|
+
--foreground: 0 0% 98%;
|
|
48
|
+
--card: 240 10% 3.9%;
|
|
49
|
+
--card-foreground: 0 0% 98%;
|
|
50
|
+
--primary: 0 0% 98%;
|
|
51
|
+
--primary-foreground: 240 5.9% 10%;
|
|
52
|
+
--secondary: 240 3.7% 15.9%;
|
|
53
|
+
--secondary-foreground: 0 0% 98%;
|
|
54
|
+
--muted: 240 3.7% 15.9%;
|
|
55
|
+
--muted-foreground: 240 5% 64.9%;
|
|
56
|
+
--accent: 240 3.7% 15.9%;
|
|
57
|
+
--accent-foreground: 0 0% 98%;
|
|
58
|
+
--destructive: 0 62.8% 30.6%;
|
|
59
|
+
--destructive-foreground: 0 0% 98%;
|
|
60
|
+
--border: 240 3.7% 15.9%;
|
|
61
|
+
--input: 240 3.7% 15.9%;
|
|
62
|
+
--ring: 240 4.9% 83.9%;
|
|
63
|
+
}
|
|
55
64
|
|
|
65
|
+
@layer base {
|
|
56
66
|
* {
|
|
57
67
|
border-color: hsl(var(--border));
|
|
58
68
|
}
|
|
@@ -63,4 +73,15 @@
|
|
|
63
73
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
64
74
|
-webkit-font-smoothing: antialiased;
|
|
65
75
|
}
|
|
76
|
+
|
|
77
|
+
/* 모바일(≤640px): 입력 글꼴이 16px 미만이면 iOS 가 포커스 시 화면을 확대한다.
|
|
78
|
+
UI 킷 Input 은 text-sm(14px)이라 좁은 화면에서만 최소 글꼴을 보장한다(결정 107).
|
|
79
|
+
데스크톱은 원래 text-sm 을 그대로 둔다. */
|
|
80
|
+
@media (max-width: 640px) {
|
|
81
|
+
input,
|
|
82
|
+
textarea,
|
|
83
|
+
select {
|
|
84
|
+
font-size: var(--font-size-min);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
66
87
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// test/setup.ts — 테스트 격리 부트스트랩 (§9 실 인프라 · 결정 111).
|
|
2
|
+
//
|
|
3
|
+
// `gaon test` 가 테스트 전용 DB(<db>_test)를 만들고 마이그레이션한 뒤 vitest 를
|
|
4
|
+
// 돌린다. 이 파일이 그 DB 에 붙고(connectTestDatabase), 매 테스트 뒤 전 테이블을
|
|
5
|
+
// 비운다(truncateAll). 트랜잭션 롤백이 아니라 truncate 인 이유: service() 는 실제
|
|
6
|
+
// COMMIT 을 해서 바깥 트랜잭션으로 되돌릴 수 없다(결정 111 · agents/testing.md).
|
|
7
|
+
//
|
|
8
|
+
// 목업·인메모리 금지(§9) — 실 DB 로만 검증한다.
|
|
9
|
+
import { afterAll, afterEach, beforeAll } from 'vitest'
|
|
10
|
+
import { connectTestDatabase, truncateAll, type TestDbHandle } from 'gaonjs/testing'
|
|
11
|
+
|
|
12
|
+
let handle: TestDbHandle
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
handle = await connectTestDatabase()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await truncateAll()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
await handle?.close()
|
|
24
|
+
})
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
"resolveJsonModule": true,
|
|
12
12
|
"noEmit": true,
|
|
13
13
|
"jsx": "preserve",
|
|
14
|
-
"types": ["node", "vite/client"]
|
|
14
|
+
"types": ["node", "vite/client"],
|
|
15
|
+
"baseUrl": ".",
|
|
16
|
+
"paths": {
|
|
17
|
+
"@shared/*": ["./shared/*"]
|
|
18
|
+
}
|
|
15
19
|
},
|
|
16
20
|
"include": [
|
|
17
21
|
"apps/**/*.ts",
|
|
@@ -7,13 +7,29 @@
|
|
|
7
7
|
// 앱이 하나 이상이면 각 앱마다 vite.config.ts 를 두는 것이 아니라, 이 루트
|
|
8
8
|
// 파일 하나가 root 를 apps/<앱> 으로 잡고 여러 번 실행된다(gaon dev 가 앱별
|
|
9
9
|
// Vite 서버를 띄운다). 관례가 곧 배치.
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
10
11
|
import { defineConfig } from 'vite'
|
|
11
12
|
import vue from '@vitejs/plugin-vue'
|
|
12
13
|
|
|
14
|
+
// @shared → 프로젝트 루트의 shared/ (결정 105). UI 킷은 shared/components/ui 에
|
|
15
|
+
// 프로젝트당 한 벌만 있고, 앱 페이지는 @shared 로 참조한다. root 가 apps/web 이라
|
|
16
|
+
// shared 는 root 밖이므로, dev 서버가 읽을 수 있게 fs.allow 에 프로젝트 루트를 넣는다.
|
|
17
|
+
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
|
18
|
+
const sharedDir = fileURLToPath(new URL('./shared', import.meta.url))
|
|
19
|
+
|
|
13
20
|
export default defineConfig({
|
|
14
21
|
// 기본 앱은 apps/web · gaon dev 가 다른 앱에 대해 root 를 재정의한다.
|
|
15
22
|
root: 'apps/web',
|
|
16
23
|
plugins: [vue()],
|
|
24
|
+
resolve: {
|
|
25
|
+
// 앱 페이지에서 shared UI 킷을 @shared 로 참조한다(결정 105).
|
|
26
|
+
// 예) import Button from '@shared/components/ui/Button.vue'
|
|
27
|
+
alias: { '@shared': sharedDir },
|
|
28
|
+
},
|
|
29
|
+
server: {
|
|
30
|
+
// root(apps/web) 밖의 shared 를 dev 서버가 읽도록 허용한다.
|
|
31
|
+
fs: { allow: [projectRoot] },
|
|
32
|
+
},
|
|
17
33
|
build: {
|
|
18
34
|
outDir: '../../dist/web',
|
|
19
35
|
emptyOutDir: true,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// vitest.config.ts — 테스트 러너 설정 (§9 실 인프라 · 결정 111).
|
|
2
|
+
//
|
|
3
|
+
// `gaon test`(= vitest)가 이 설정으로 돈다. 프론트 빌드는 vite.config.ts 가,
|
|
4
|
+
// 테스트는 이 파일이 담당한다(vitest 는 vitest.config 를 우선한다).
|
|
5
|
+
//
|
|
6
|
+
// 격리: `gaon test` 가 테스트 전용 DB(<db>_test)를 만들고 마이그레이션한 뒤,
|
|
7
|
+
// test/setup.ts 가 그 DB 에 붙고 매 테스트 뒤 전 테이블을 비운다(truncate).
|
|
8
|
+
// 실 DB 하나를 공유하므로 파일 병렬을 끈다 — 병렬이면 서로의 데이터를 지운다.
|
|
9
|
+
import { defineConfig } from 'vitest/config'
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
test: {
|
|
13
|
+
setupFiles: ['./test/setup.ts'],
|
|
14
|
+
include: [
|
|
15
|
+
'test/**/*.test.ts',
|
|
16
|
+
'domain/**/*.test.ts',
|
|
17
|
+
'apps/**/*.test.ts',
|
|
18
|
+
'shared/**/*.test.ts',
|
|
19
|
+
],
|
|
20
|
+
// 실 DB 를 공유하는 통합 테스트 격리 — 파일 병렬 금지(§9 · 결정 111).
|
|
21
|
+
fileParallelism: false,
|
|
22
|
+
},
|
|
23
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// UI 킷 블록 · EmptyState (결정 106). 빈 목록·무결과 안내. 아이콘(슬롯)·제목·
|
|
3
|
+
// 설명·액션(슬롯)으로 조합한다. 성격 중립 — 관리자/프론트 구분 없이 전 앱에서 쓴다.
|
|
4
|
+
import { useSlots } from 'vue'
|
|
5
|
+
|
|
6
|
+
defineProps<{ title?: string; description?: string }>()
|
|
7
|
+
const slots = useSlots()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div
|
|
12
|
+
class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 py-12 text-center"
|
|
13
|
+
>
|
|
14
|
+
<div v-if="slots.icon" class="text-muted-foreground"><slot name="icon" /></div>
|
|
15
|
+
<div class="space-y-1">
|
|
16
|
+
<p class="text-sm font-medium"><slot name="title">{{ title }}</slot></p>
|
|
17
|
+
<p v-if="description || slots.description" class="text-sm text-muted-foreground">
|
|
18
|
+
<slot name="description">{{ description }}</slot>
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
21
|
+
<div v-if="slots.action" class="mt-1"><slot name="action" /></div>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// UI 킷 블록 · PageHeader (결정 106). 페이지 상단 — 제목 + 설명 + 액션(슬롯).
|
|
3
|
+
// 모바일에선 액션이 제목 아래로 줄바꿈되고 넓은 화면에선 오른쪽에 붙는다
|
|
4
|
+
// (반응형은 블록 책임 · 결정 107). 액션 슬롯이 없으면 액션 영역은 렌더되지 않는다.
|
|
5
|
+
import { useSlots } from 'vue'
|
|
6
|
+
|
|
7
|
+
defineProps<{ title?: string; description?: string }>()
|
|
8
|
+
const slots = useSlots()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
13
|
+
<div class="min-w-0 space-y-1">
|
|
14
|
+
<h1 class="truncate text-2xl font-bold tracking-tight">
|
|
15
|
+
<slot name="title">{{ title }}</slot>
|
|
16
|
+
</h1>
|
|
17
|
+
<p v-if="description || slots.description" class="text-sm text-muted-foreground">
|
|
18
|
+
<slot name="description">{{ description }}</slot>
|
|
19
|
+
</p>
|
|
20
|
+
</div>
|
|
21
|
+
<div v-if="slots.actions" class="flex shrink-0 flex-wrap items-center gap-2">
|
|
22
|
+
<slot name="actions" />
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// UI 킷 블록 · PageShell (결정 106). 페이지 바깥 골격 — 최대폭·좌우 여백·세로
|
|
3
|
+
// 리듬을 한 곳에서 소유한다. 반응형(폭·여백)은 이 블록이 책임지므로(결정 107)
|
|
4
|
+
// 페이지 코드는 레이아웃 브레이크포인트를 직접 쓰지 않는다. size 로 최대폭을 고른다.
|
|
5
|
+
import { computed } from 'vue'
|
|
6
|
+
import { cn } from '../../lib/utils.js'
|
|
7
|
+
|
|
8
|
+
type Size = 'default' | 'narrow' | 'wide' | 'full'
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{ size?: Size }>(), { size: 'default' })
|
|
11
|
+
|
|
12
|
+
const MAX: Record<Size, string> = {
|
|
13
|
+
narrow: 'max-w-2xl',
|
|
14
|
+
default: 'max-w-4xl',
|
|
15
|
+
wide: 'max-w-6xl',
|
|
16
|
+
full: 'max-w-none',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 좌우 여백·세로 리듬을 브레이크포인트로 한 곳에서 정한다 — 페이지는 이 값을 모른다.
|
|
20
|
+
const classes = computed(() =>
|
|
21
|
+
cn('mx-auto w-full px-4 py-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10', MAX[props.size]),
|
|
22
|
+
)
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div :class="classes"><slot /></div>
|
|
27
|
+
</template>
|