@gaonjs/cli 0.23.0 → 0.25.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/README.md +10 -3
- package/dist/commands/db.js +2 -2
- package/dist/db.d.ts +7 -2
- package/dist/db.js +30 -17
- package/dist/doctor/async-offload.d.ts +23 -0
- package/dist/doctor/async-offload.js +236 -0
- package/dist/doctor/page-layout-breakpoint.d.ts +8 -0
- package/dist/doctor/page-layout-breakpoint.js +94 -0
- package/dist/doctor/pageprops-destructure.d.ts +5 -0
- package/dist/doctor/pageprops-destructure.js +84 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +1 -1
- package/dist/doctor/ui-kit-wiring.js +11 -7
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +17 -2
- package/dist/index.js +2 -1
- package/dist/templates/auth/Dashboard.vue.tpl +12 -11
- package/dist/templates/auth/Login.vue.tpl +18 -16
- package/dist/templates/auth/Signup.vue.tpl +16 -15
- package/dist/templates/project/AGENTS.md.tpl +25 -2
- package/dist/templates/project/agents/async.md.tpl +55 -0
- package/dist/templates/project/agents/data.md.tpl +31 -0
- package/dist/templates/project/agents/frontend.md.tpl +76 -25
- package/dist/templates/project/agents/web.md.tpl +20 -7
- 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/tsconfig.json.tpl +5 -1
- package/dist/templates/project/vite.config.ts.tpl +16 -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 +6 -6
- package/dist/templates/auth/app.ts.tpl +0 -29
- package/dist/templates/auth/server.ts.tpl +0 -14
package/dist/doctor.d.ts
CHANGED
|
@@ -17,6 +17,9 @@ export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } fr
|
|
|
17
17
|
export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
|
|
18
18
|
export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js';
|
|
19
19
|
export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
|
|
20
|
+
export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
|
|
21
|
+
export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
|
|
22
|
+
export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
|
|
20
23
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
21
24
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
22
25
|
export interface DoctorCommandOptions {
|
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 22 검사를 조립한다:
|
|
5
5
|
* 1) response-mixing (errata E-3 §C · 라이브)
|
|
6
6
|
* 2) n-plus-one (errata E-4 (e))
|
|
7
7
|
* 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* 17) method-override (결정 89 · _method HTTP 스푸핑 hack 경고)
|
|
22
22
|
* 18) csrf-wiring (결정 93 · 비-GET 라우트 + session 미배선 = CSRF 무방비 경고)
|
|
23
23
|
* 19) internal-anchor (결정 96 · 앱 내부 이동 일반 <a> = 풀 리로드 경고)
|
|
24
|
+
* 20) pageprops-destructure (결정 99 · pageProps() 구조분해 = 반응성 끊김 경고)
|
|
25
|
+
* 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
|
|
26
|
+
* 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
|
|
24
27
|
*
|
|
25
28
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
26
29
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -52,6 +55,9 @@ import { checkStaticCollision } from './doctor/static-collision.js';
|
|
|
52
55
|
import { checkMethodOverride } from './doctor/method-override.js';
|
|
53
56
|
import { checkCsrfWiring } from './doctor/csrf-wiring.js';
|
|
54
57
|
import { checkInternalAnchor } from './doctor/internal-anchor.js';
|
|
58
|
+
import { checkPagePropsDestructure } from './doctor/pageprops-destructure.js';
|
|
59
|
+
import { checkAsyncOffload } from './doctor/async-offload.js';
|
|
60
|
+
import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
|
|
55
61
|
import { renderHuman, renderJson } from './doctor/reporter.js';
|
|
56
62
|
import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
57
63
|
import { makeResult, } from './doctor/types.js';
|
|
@@ -72,10 +78,13 @@ export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } fr
|
|
|
72
78
|
export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
|
|
73
79
|
export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js';
|
|
74
80
|
export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
|
|
81
|
+
export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
|
|
82
|
+
export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
|
|
83
|
+
export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
|
|
75
84
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
76
85
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
77
86
|
/**
|
|
78
|
-
* 실행할 검사 이름. 지정 없음(undefined) =
|
|
87
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 21개 모두.
|
|
79
88
|
*/
|
|
80
89
|
const ALL_RULES = [
|
|
81
90
|
'response-mixing',
|
|
@@ -97,6 +106,9 @@ const ALL_RULES = [
|
|
|
97
106
|
'method-override',
|
|
98
107
|
'csrf-wiring',
|
|
99
108
|
'internal-anchor',
|
|
109
|
+
'pageprops-destructure',
|
|
110
|
+
'async-offload',
|
|
111
|
+
'page-layout-breakpoint',
|
|
100
112
|
];
|
|
101
113
|
const CHECKERS = {
|
|
102
114
|
'response-mixing': checkResponseMixing,
|
|
@@ -118,6 +130,9 @@ const CHECKERS = {
|
|
|
118
130
|
'method-override': checkMethodOverride,
|
|
119
131
|
'csrf-wiring': checkCsrfWiring,
|
|
120
132
|
'internal-anchor': checkInternalAnchor,
|
|
133
|
+
'pageprops-destructure': checkPagePropsDestructure,
|
|
134
|
+
'async-offload': checkAsyncOffload,
|
|
135
|
+
'page-layout-breakpoint': checkPageLayoutBreakpoint,
|
|
121
136
|
};
|
|
122
137
|
/**
|
|
123
138
|
* 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
|
package/dist/index.js
CHANGED
|
@@ -101,7 +101,7 @@ function renderHelp(version = VERSION) {
|
|
|
101
101
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
102
102
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
103
103
|
" gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
|
|
104
|
-
" gaon doctor 정적 검사 (
|
|
104
|
+
" gaon doctor 정적 검사 (22 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트)",
|
|
105
105
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
106
106
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
107
107
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -147,6 +147,7 @@ export function parseDoctorChecks(argv) {
|
|
|
147
147
|
"shared-composable-purity",
|
|
148
148
|
"no-auto-import",
|
|
149
149
|
"csrf-wiring",
|
|
150
|
+
"async-offload",
|
|
150
151
|
];
|
|
151
152
|
const isKnown = (s) => known.includes(s);
|
|
152
153
|
const out = [];
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, router } from 'gaonjs/vue'
|
|
3
|
-
import Card from '
|
|
4
|
-
import CardHeader from '
|
|
5
|
-
import CardTitle from '
|
|
6
|
-
import CardDescription from '
|
|
7
|
-
import CardContent from '
|
|
8
|
-
import CardFooter from '
|
|
9
|
-
import Button from '
|
|
3
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '@shared/components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '@shared/components/ui/CardContent.vue'
|
|
8
|
+
import CardFooter from '@shared/components/ui/CardFooter.vue'
|
|
9
|
+
import Button from '@shared/components/ui/Button.vue'
|
|
10
10
|
|
|
11
11
|
// dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
|
|
12
|
-
|
|
12
|
+
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
13
|
+
const props = pageProps<'{{APP_NAME}}:dashboard#show'>()
|
|
13
14
|
|
|
14
15
|
// 로그아웃 = DELETE /session (r.resource('session') 의 destroy).
|
|
15
16
|
// HTML <form> 은 DELETE 를 보낼 수 없으므로 Inertia 라우터로 실제 메서드를
|
|
16
17
|
// 보낸다(결정 64) — `?_method=DELETE` 우회는 서버가 해석하지 않아 POST /session
|
|
17
18
|
// (= 로그인 create) 으로 잘못 라우팅됐다.
|
|
18
19
|
function logout(): void {
|
|
19
|
-
router.delete('/session', { headers: { 'x-csrf-token': csrf } })
|
|
20
|
+
router.delete('/session', { headers: { 'x-csrf-token': props.csrf } })
|
|
20
21
|
}
|
|
21
22
|
</script>
|
|
22
23
|
|
|
@@ -24,8 +25,8 @@ function logout(): void {
|
|
|
24
25
|
<div class="mx-auto max-w-2xl px-4 py-10">
|
|
25
26
|
<Card>
|
|
26
27
|
<CardHeader>
|
|
27
|
-
<CardTitle>환영합니다, {{ user.name }}님</CardTitle>
|
|
28
|
-
<CardDescription>{{ user.email }}</CardDescription>
|
|
28
|
+
<CardTitle>환영합니다, {{ props.user.name }}님</CardTitle>
|
|
29
|
+
<CardDescription>{{ props.user.email }}</CardDescription>
|
|
29
30
|
</CardHeader>
|
|
30
31
|
<CardContent>
|
|
31
32
|
<p class="text-sm text-muted-foreground">보호된 페이지입니다 — this.requireAuth() 로 지킵니다.</p>
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, useForm, Link } from 'gaonjs/vue'
|
|
3
|
-
import Card from '
|
|
4
|
-
import CardHeader from '
|
|
5
|
-
import CardTitle from '
|
|
6
|
-
import CardDescription from '
|
|
7
|
-
import CardContent from '
|
|
8
|
-
import Form from '
|
|
9
|
-
import FormField from '
|
|
10
|
-
import Input from '
|
|
11
|
-
import Button from '
|
|
12
|
-
import Alert from '
|
|
13
|
-
import AlertDescription from '
|
|
3
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '@shared/components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '@shared/components/ui/CardContent.vue'
|
|
8
|
+
import Form from '@shared/components/ui/Form.vue'
|
|
9
|
+
import FormField from '@shared/components/ui/FormField.vue'
|
|
10
|
+
import Input from '@shared/components/ui/Input.vue'
|
|
11
|
+
import Button from '@shared/components/ui/Button.vue'
|
|
12
|
+
import Alert from '@shared/components/ui/Alert.vue'
|
|
13
|
+
import AlertDescription from '@shared/components/ui/AlertDescription.vue'
|
|
14
14
|
|
|
15
|
-
// 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2).
|
|
16
|
-
|
|
15
|
+
// 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2). pageProps 는
|
|
16
|
+
// 반응형이라 변수로 받아 props.x 로 접근한다 — 구조분해 금지(결정 99). 로그인
|
|
17
|
+
// 실패로 서버가 같은 페이지를 다시 render 하면 props.error 가 즉시 갱신된다.
|
|
18
|
+
const props = pageProps<'{{APP_NAME}}:session#new'>()
|
|
17
19
|
|
|
18
20
|
// 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
|
|
19
21
|
// 서버는 redirect(Inertia 응답)로 답하고, 실패 시 같은 페이지를 다시 render 한다.
|
|
20
|
-
const form = useForm({ email: '', password: '', _csrf: csrf })
|
|
22
|
+
const form = useForm({ email: '', password: '', _csrf: props.csrf })
|
|
21
23
|
</script>
|
|
22
24
|
|
|
23
25
|
<template>
|
|
@@ -28,8 +30,8 @@ const form = useForm({ email: '', password: '', _csrf: csrf })
|
|
|
28
30
|
<CardDescription>계정으로 로그인하세요.</CardDescription>
|
|
29
31
|
</CardHeader>
|
|
30
32
|
<CardContent>
|
|
31
|
-
<Alert v-if="error" variant="destructive" class="mb-4">
|
|
32
|
-
<AlertDescription>{{ error }}</AlertDescription>
|
|
33
|
+
<Alert v-if="props.error" variant="destructive" class="mb-4">
|
|
34
|
+
<AlertDescription>{{ props.error }}</AlertDescription>
|
|
33
35
|
</Alert>
|
|
34
36
|
<Form @submit="form.post('/session')">
|
|
35
37
|
<FormField label="이메일" :error="form.errors.email">
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { pageProps, useForm, Link } from 'gaonjs/vue'
|
|
3
|
-
import Card from '
|
|
4
|
-
import CardHeader from '
|
|
5
|
-
import CardTitle from '
|
|
6
|
-
import CardDescription from '
|
|
7
|
-
import CardContent from '
|
|
8
|
-
import Form from '
|
|
9
|
-
import FormField from '
|
|
10
|
-
import Input from '
|
|
11
|
-
import Button from '
|
|
12
|
-
import Alert from '
|
|
13
|
-
import AlertDescription from '
|
|
3
|
+
import Card from '@shared/components/ui/Card.vue'
|
|
4
|
+
import CardHeader from '@shared/components/ui/CardHeader.vue'
|
|
5
|
+
import CardTitle from '@shared/components/ui/CardTitle.vue'
|
|
6
|
+
import CardDescription from '@shared/components/ui/CardDescription.vue'
|
|
7
|
+
import CardContent from '@shared/components/ui/CardContent.vue'
|
|
8
|
+
import Form from '@shared/components/ui/Form.vue'
|
|
9
|
+
import FormField from '@shared/components/ui/FormField.vue'
|
|
10
|
+
import Input from '@shared/components/ui/Input.vue'
|
|
11
|
+
import Button from '@shared/components/ui/Button.vue'
|
|
12
|
+
import Alert from '@shared/components/ui/Alert.vue'
|
|
13
|
+
import AlertDescription from '@shared/components/ui/AlertDescription.vue'
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
16
|
+
const props = pageProps<'{{APP_NAME}}:registration#new'>()
|
|
16
17
|
|
|
17
18
|
// 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
|
|
18
|
-
const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
|
|
19
|
+
const form = useForm({ name: '', email: '', password: '', _csrf: props.csrf })
|
|
19
20
|
</script>
|
|
20
21
|
|
|
21
22
|
<template>
|
|
@@ -26,8 +27,8 @@ const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
|
|
|
26
27
|
<CardDescription>새 계정을 만드세요.</CardDescription>
|
|
27
28
|
</CardHeader>
|
|
28
29
|
<CardContent>
|
|
29
|
-
<Alert v-if="error" variant="destructive" class="mb-4">
|
|
30
|
-
<AlertDescription>{{ error }}</AlertDescription>
|
|
30
|
+
<Alert v-if="props.error" variant="destructive" class="mb-4">
|
|
31
|
+
<AlertDescription>{{ props.error }}</AlertDescription>
|
|
31
32
|
</Alert>
|
|
32
33
|
<Form @submit="form.post('/registration')">
|
|
33
34
|
<FormField label="이름" :error="form.errors.name">
|
|
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
104
104
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
105
105
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
106
106
|
|
|
107
|
-
### 2.2 `gaon doctor` 검사
|
|
107
|
+
### 2.2 `gaon doctor` 검사 22종
|
|
108
108
|
|
|
109
109
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
110
110
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -125,6 +125,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
125
125
|
17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
|
|
126
126
|
18. `csrf-wiring` — 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 `app.config.ts` 에 session 미배선 = CSRF 무방비 (결정 93 · 경고)
|
|
127
127
|
19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
|
|
128
|
+
20. `pageprops-destructure` — `const { x } = pageProps(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라) (결정 99 · 경고)
|
|
129
|
+
21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
|
|
130
|
+
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
128
131
|
|
|
129
132
|
## 3. 로직 배치 One Way 판단표
|
|
130
133
|
|
|
@@ -158,6 +161,26 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
158
161
|
위치를 강제하지 않는다 (`agents/async.md` §2). DB 커밋 정합이 필요하면
|
|
159
162
|
서비스 `afterCommit()` 또는 아웃박스.
|
|
160
163
|
|
|
164
|
+
### 3.5 비동기 배치 (동기 인라인 vs 잡/이벤트/스케줄 · 결정 102)
|
|
165
|
+
|
|
166
|
+
§3.1~§3.4 가 "로직을 **어느 층**에 둘까"라면, 이 표는 "그 로직을 **동기로 둘까
|
|
167
|
+
비동기로 뺄까**"의 One Way 다. 상세·예시는 `agents/async.md` 서두 판단표.
|
|
168
|
+
|
|
169
|
+
| 상황 | 배치 | 이유 |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| 응답에 결과가 **필요** | 요청 인라인 | 사용자가 그 값을 기다린다 |
|
|
172
|
+
| 응답 불필요 · 실패 시 재시도 (메일·알림·웹훅) | **잡** (`domain/jobs/`) | 응답 지연 제거 + 재시도(백오프·DLQ) |
|
|
173
|
+
| 느리거나 불안정한 **외부 API** | **잡** | 이벤트 루프·응답 시간 보호 |
|
|
174
|
+
| **CPU 무거운** 작업 (이미지 리사이즈·리포트·대량 연산) | **잡** | 루프 블로킹 = 이웃 요청 전부 정지 |
|
|
175
|
+
| 트랜잭션 커밋 **파생 효과** | **이벤트 + 아웃박스** (`afterCommit`) | DB 확정과 정합 (롤백 시 발행 취소) |
|
|
176
|
+
| **주기** 작업 (정리·집계·랭킹) | **`domain/schedule.ts`** | 요청과 무관한 시간축 · 리더 1인만 |
|
|
177
|
+
|
|
178
|
+
**실무 신호**: 요청 중 수백 ms 이상 걸리는 일은 잡 후보. Node 는 싱글 스레드라
|
|
179
|
+
무거운 작업 하나가 이웃 요청을 세운다. **"잡으로 뺀다 = `work` 프로세스로
|
|
180
|
+
옮긴다"** — 인라인은 `serve`(웹)가, 잡·`afterCommit`·`schedule` 은 `work` 가
|
|
181
|
+
처리하므로 웹 응답이 그 무게를 지지 않는다. `gaon doctor` 의 `async-offload`
|
|
182
|
+
가 컨트롤러 인라인 메일·이미지 처리·외부 HTTP 를 경고로 잡는다(결정 103).
|
|
183
|
+
|
|
161
184
|
## 4. 검증 루프
|
|
162
185
|
|
|
163
186
|
작업마다 실행한다:
|
|
@@ -165,7 +188,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
165
188
|
```bash
|
|
166
189
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
|
|
167
190
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
168
|
-
gaon doctor # 정적 검사
|
|
191
|
+
gaon doctor # 정적 검사 22종 (§2.2)
|
|
169
192
|
```
|
|
170
193
|
|
|
171
194
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -5,6 +5,54 @@
|
|
|
5
5
|
> 대상 패키지: `@gaonjs/async` (파사드 import 는 `gaonjs/async`). 백본 = NATS JetStream ·
|
|
6
6
|
> 실행은 워커 프로세스(`gaon work`).
|
|
7
7
|
|
|
8
|
+
## 비동기 배치의 One Way 판단표 (결정 102)
|
|
9
|
+
|
|
10
|
+
`agents/data.md` §5.3 / `AGENTS.md` §3.1 이 **서버 로직을 어느 층**(모델·서비스·
|
|
11
|
+
컨트롤러)에 둘지 정한다면, 이 표는 그 로직을 **동기(요청 인라인)로 둘지 비동기
|
|
12
|
+
(잡·이벤트·스케줄)로 뺄지**를 정한다 — 같은 "정답이 하나" 원칙의 비동기 축이다.
|
|
13
|
+
|
|
14
|
+
| 상황 | 배치 | 이유 |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| 응답에 결과가 **필요** | 요청 인라인 | 사용자가 그 값을 기다린다 (조회·검증·생성 결과) |
|
|
17
|
+
| 응답 불필요 · 실패 시 재시도 (메일·알림·웹훅) | **잡** (`domain/jobs/`) | 응답 지연 제거 + 재시도 배터리(백오프·DLQ) |
|
|
18
|
+
| 느리거나 불안정한 **외부 API** 호출 | **잡** | 이벤트 루프·응답 시간 보호 (외부 지연이 응답에 새지 않음) |
|
|
19
|
+
| **CPU 무거운** 작업 (이미지 리사이즈·리포트·대량 연산) | **잡** | 루프 블로킹 = 그 사이 이웃 요청 전부 정지 |
|
|
20
|
+
| 트랜잭션 커밋에 딸린 **파생 효과** | **이벤트 + 아웃박스** (`afterCommit`) | DB 확정과 정합 (롤백 시 발행도 취소 · §4) |
|
|
21
|
+
| **주기** 작업 (정리·집계·랭킹) | **`domain/schedule.ts`** | 요청과 무관한 시간축 · 리더 1인만 실행 |
|
|
22
|
+
|
|
23
|
+
**실무 신호**: 요청 처리 중 **수백 ms 이상** 걸리는 일은 잡 후보다. Node 는 싱글
|
|
24
|
+
스레드라 무거운 작업 하나가 워커 앞에 이웃 요청을 전부 세운다(head-of-line
|
|
25
|
+
blocking). 확신이 안 서면 "응답에 이 결과가 필요한가?" 만 묻는다 — 아니면 잡이다.
|
|
26
|
+
|
|
27
|
+
**"잡으로 뺀다 = work 프로세스로 옮긴다".** 운영 프로세스는 3종(§6): `gaon serve`
|
|
28
|
+
(웹 요청) · `gaon work`(잡·리스너·스케줄·아웃박스 릴레이) · `gaon hub`(실시간).
|
|
29
|
+
인라인 코드는 `serve` 안에서 요청 스레드로 돈다. `.later()`·`afterCommit`·
|
|
30
|
+
`schedule.ts` 로 뺀 일은 **`work` 프로세스**가 별도로 처리하므로 웹 응답이 그
|
|
31
|
+
무게를 지지 않는다. 그래서 "느리다/무겁다/실패해도 재시도" 는 전부 `work` 행(行)이다.
|
|
32
|
+
|
|
33
|
+
각 배치의 정본 예시 (전체 시그니처·옵션은 아래 §1·§4·§5):
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// 잡 — 응답과 분리해 발행 (메일·알림·이미지 처리 등). 컨트롤러/서비스에서:
|
|
37
|
+
await SendWelcomeMail.later(user.id) // domain/jobs/sendWelcomeMail.ts (§1)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// 파생 효과 — 커밋 뒤에만 나가야 하는 발행은 서비스 afterCommit (§4 아웃박스).
|
|
42
|
+
export const RegisterUser = service(async (input: RegisterInput) => {
|
|
43
|
+
const user = await User.create(input)
|
|
44
|
+
afterCommit(() => ResizeAvatar.later(user.id)) // 커밋 성공 후에만 발행
|
|
45
|
+
return user
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// 주기 작업 — domain/schedule.ts · 대상은 항상 잡 (§5 · 인라인 함수 금지).
|
|
51
|
+
export default schedule((s) => {
|
|
52
|
+
s.cron('0 9 * * 1', SendWeeklyReport) // 매주 월 09:00 · 리더 1인만
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
8
56
|
## 정본 규칙
|
|
9
57
|
|
|
10
58
|
### 1. 잡 (`job()`) (`packages/async/src/jobs.ts:146-185`)
|
|
@@ -209,6 +257,11 @@ async create() {
|
|
|
209
257
|
|
|
210
258
|
- **컨트롤러에서 메일·외부 발송 직접 호출 = 함정** — 컨트롤러는 잡
|
|
211
259
|
발행만. nodemailer·resend·@sendgrid/mail 직접 import 금지.
|
|
260
|
+
- **무거운/느린 작업을 요청 인라인에 두면 함정** (판단표 · 결정 102) —
|
|
261
|
+
이미지 리사이즈(sharp·jimp)·외부 API 호출을 컨트롤러 액션 안에서 동기로
|
|
262
|
+
하면 응답이 그 무게를 진다. `domain/jobs/` 잡으로 빼고 `.later()` 로 발행한다.
|
|
263
|
+
`gaon doctor` 의 `async-offload` 가 이 인라인 패턴을 경고로 잡는다(결정 103 ·
|
|
264
|
+
JSON/API 앱의 정당한 외부 호출은 오탐 방지로 제외).
|
|
212
265
|
- **클래스형 잡·데코레이터(`@Job`·`@Processor`) 금지** — `job()` 함수형만.
|
|
213
266
|
- **잡 파일 위치는 `domain/jobs/`** — 앱 폴더가 아니다 (잡은 도메인
|
|
214
267
|
소속 · 어느 앱에서든 큐잉).
|
|
@@ -225,6 +278,8 @@ async create() {
|
|
|
225
278
|
|
|
226
279
|
| 결정 | 내용 |
|
|
227
280
|
|---|---|
|
|
281
|
+
| 결정 102 | 비동기 배치 One Way 판단표 (동기 인라인 vs 잡/이벤트/스케줄 · 서두 표) |
|
|
282
|
+
| 결정 103 | doctor `async-offload` 검사 (컨트롤러 인라인 메일·이미지·외부 HTTP 경고) |
|
|
228
283
|
| 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
|
|
229
284
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
|
|
230
285
|
| §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
|
|
@@ -395,6 +395,33 @@ export const Post = model(posts, {
|
|
|
395
395
|
})
|
|
396
396
|
```
|
|
397
397
|
|
|
398
|
+
### 8.1 스키마 파생 폼 — `Model.form` · `Model.form.pick()` (결정 104)
|
|
399
|
+
|
|
400
|
+
모든 모델은 `Model.form` 으로 **스키마 파생 폼**을 노출한다 — 컨트롤러의
|
|
401
|
+
`this.params(Model.form)` 에 넘기면 컬럼 타입으로 런타임 강제 변환·검증
|
|
402
|
+
(필수 강제 · 스키마 밖 키 제거 = 대량 할당 차단)까지 한다. 컨트롤러 쪽
|
|
403
|
+
사용법(폼 모양 판단·라우트 파라미터 병합)은 `agents/web.md` §3 이 정본이다.
|
|
404
|
+
|
|
405
|
+
**일부 컬럼만 검증해서 받으려면 `pick()`** — 지정한 컬럼만 담은 **새 폼**을
|
|
406
|
+
돌려준다(원 폼 불변). 컬럼 타입·검증·기본값 정보가 그대로 따라오므로,
|
|
407
|
+
"검증되는 부분 폼"이 필요할 때 애드혹 `{ _row: {} as T }`(검증 없음) 대신 쓴다.
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
// routes: r.post('/posts/:postId/comments', 'comments#create')
|
|
411
|
+
// comments 스키마에서 postId·author·body 만 — :postId 는 라우트에서 자동 병합(결정 95).
|
|
412
|
+
async create() {
|
|
413
|
+
const data = this.params(Comment.form.pick('postId', 'author', 'body'))
|
|
414
|
+
// data: { postId: bigint; author: string; body: string } — 안 고른 컬럼은 요구하지 않는다.
|
|
415
|
+
const comment = await Comment.create(data)
|
|
416
|
+
return this.redirect(`/posts/${String(data.postId)}`)
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
- `pick()` 결과도 폼이다 — 부분집합으로 다시 `pick()` 할 수 있다.
|
|
421
|
+
- 스키마에 없는 컬럼을 지정하면 즉시 throw(수리 안내 포함) · 빈
|
|
422
|
+
`pick()` 도 throw · 중복 지정은 조용히 하나로 합친다.
|
|
423
|
+
- `omit`·`extend`·`merge` 는 **없다** — 폼 변형은 `pick()` 하나가 The One Way.
|
|
424
|
+
|
|
398
425
|
### 9. 서비스 (`service()`) — 트랜잭션 작업 흐름 (정본 §5.3 · `packages/data/src/service.ts`)
|
|
399
426
|
|
|
400
427
|
로직 배치의 One Way 규칙은 루트 `AGENTS.md` 판단표가 정본이다 (정본 §5.3):
|
|
@@ -601,6 +628,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
601
628
|
doctor **model-filename** 이 잡는다(`--fix` 지원 · 결정 32·46).
|
|
602
629
|
- **잡·이벤트 발행을 트랜잭션과 정합시키려면** `afterCommit()`(service 안)
|
|
603
630
|
또는 아웃박스(`agents/async.md`) — 커밋 전 발행은 롤백 시 유령 부수효과.
|
|
631
|
+
- **폼 변형은 `pick()` 뿐** (결정 104) — `Model.form.omit/extend/merge` 는
|
|
632
|
+
없다. 검증되는 부분 폼 = `pick()`, 스키마와 무관한 입력만 애드혹
|
|
633
|
+
`{ _row: {} as T }`(검증 없음 · `agents/web.md` §3).
|
|
604
634
|
|
|
605
635
|
## 관련 결정 번호
|
|
606
636
|
|
|
@@ -615,4 +645,5 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
615
645
|
| 결정 43 | 네이밍 정본화 · DB 네이밍 SSOT(§1.2 · 테이블 snake · 컬럼 camel) |
|
|
616
646
|
| 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
|
|
617
647
|
| 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
|
|
648
|
+
| 결정 104 | 스키마 파생 폼에 name·defs 탑재(실검증) · `Model.form.pick()` 검증되는 부분 폼(§8.1) |
|
|
618
649
|
| E-4 | 컬럼 타입·수식어·체이닝 확장 · `Post.query()` 정정 · Serialized 명명 |
|
|
@@ -31,6 +31,13 @@ const props = pageProps<'web:posts#index'>()
|
|
|
31
31
|
</template>
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
- **변수로 받아 `props.x` 로 접근 — 구조분해 금지** (결정 99). `pageProps()` 는
|
|
35
|
+
반응형 프록시라, 같은 페이지로의 리다이렉트/리로드로 서버가 새 props 를 주면
|
|
36
|
+
(댓글 작성·삭제 등) 재마운트 없이 화면이 즉시 갱신된다. 하지만
|
|
37
|
+
`const { posts } = pageProps<...>()` 처럼 구조분해하면 그 순간 값을 한 번
|
|
38
|
+
읽어 **반응성이 끊긴다**(Vue `defineProps` 를 구조분해하면 안 되는 것과 같은
|
|
39
|
+
함정). 항상 `const props = pageProps<...>()` 로 받아 `props.posts` 로 쓴다.
|
|
40
|
+
구조분해는 doctor **pageprops-destructure** 가 잡는다(경고).
|
|
34
41
|
- **라우트 키** = `<app>:<controller>#<action>` (앱 폴더명 · 컨트롤러 파일명
|
|
35
42
|
stem · 소문자 복수 · 결정 55). `apps/web/controllers/posts.ts` 의 `index`
|
|
36
43
|
액션 → `'web:posts#index'`.
|
|
@@ -142,6 +149,11 @@ shared/composables/useDebounce.ts # 앱 간 공용 컴포저블 (순수 로
|
|
|
142
149
|
`gaon doctor` 의 **shared-composable-purity** 검사가 shared 안에서
|
|
143
150
|
`gaonjs/vue` 의 `api`/`pageProps` import 를 잡는다.
|
|
144
151
|
|
|
152
|
+
**shared 컴포넌트(UI 킷 §8)의 허용/금지도 같은 기준(결정 25·105):** 허용 = 라우트 키와
|
|
153
|
+
무관한 범용 API(`useForm`·`Link`·`router`) · 금지 = 앱 라우트 지식(`api()`·`pageProps`).
|
|
154
|
+
그래서 킷의 `Form` 은 `useForm` 을 써도 되고, `Pagination` 은 라우트를 모른 채
|
|
155
|
+
`v-model:page` 로 현재 페이지만 올려보낸다.
|
|
156
|
+
|
|
145
157
|
### 5. 레이아웃 관례 (errata E-5 §2.3)
|
|
146
158
|
|
|
147
159
|
```
|
|
@@ -188,37 +200,55 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
|
|
|
188
200
|
·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
|
|
189
201
|
소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
|
|
190
202
|
|
|
191
|
-
### 8. UI 킷 (결정 74·75 · shadcn 참조 · 복사-소유)
|
|
203
|
+
### 8. UI 킷 (결정 74·75 · 결정 105 shared 이전 · shadcn 참조 · 복사-소유)
|
|
192
204
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
의존이 아니라 파일이므로 자유롭게 고친다
|
|
196
|
-
(clsx·tailwind-merge·cva·reka-ui 미도입 · `cn` 은
|
|
197
|
-
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 자작).
|
|
198
210
|
|
|
199
|
-
배치·import
|
|
211
|
+
배치·import — 앱 페이지는 `@shared` alias 로 참조한다:
|
|
200
212
|
|
|
201
213
|
```
|
|
202
|
-
|
|
203
|
-
|
|
214
|
+
shared/lib/utils.ts # cn() — 조건부 클래스 병합
|
|
215
|
+
shared/components/ui/*.vue # 원자 + 블록 (전 앱 공용 순수 UI)
|
|
204
216
|
```
|
|
205
217
|
|
|
206
218
|
```vue
|
|
207
219
|
<script setup lang="ts">
|
|
208
|
-
import Button from '
|
|
209
|
-
import Card from '
|
|
210
|
-
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'
|
|
211
223
|
</script>
|
|
212
224
|
|
|
213
225
|
<template>
|
|
214
|
-
<
|
|
215
|
-
<
|
|
216
|
-
|
|
217
|
-
</CardContent>
|
|
218
|
-
</Card>
|
|
226
|
+
<PageShell>
|
|
227
|
+
<Card><Button variant="default">저장</Button></Card> <!-- variant·size 로 모양 선택 -->
|
|
228
|
+
</PageShell>
|
|
219
229
|
</template>
|
|
220
230
|
```
|
|
221
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** 는 강제가 아닌 **안내 경고**다.)
|
|
222
252
|
- **폼은 UI 킷 Form + gaonjs `useForm`(결정 64)** — `Form` 은 얇은 `<form>` 래퍼로
|
|
223
253
|
`@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
|
|
224
254
|
않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
|
|
@@ -229,13 +259,23 @@ import CardContent from '../../components/ui/CardContent.vue'
|
|
|
229
259
|
- **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
|
|
230
260
|
`text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
|
|
231
261
|
`:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
|
|
232
|
-
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
262
|
+
- **shared 킷의 허용/금지 API(결정 25·105)** — 킷은 `shared/` 라 라우트를 몰라야
|
|
263
|
+
한다: 허용 = 라우트 키와 무관한 범용 API(`useForm`·`Link`·`router`) · 금지 =
|
|
264
|
+
앱 라우트 지식(`api()`·`pageProps`). 데이터는 props 로 받는다(예 `Pagination` 은
|
|
265
|
+
`v-model:page` 로 현재 페이지만 올려보내고 실제 이동은 페이지가 정한다).
|
|
266
|
+
- **멀티앱은 앱마다 Tailwind 배선이 따로다(결정 76)** — 킷은 shared 한 벌이지만,
|
|
267
|
+
각 앱이 Tailwind 유틸을 받으려면 그 앱에 `style.css` 배선이 있어야 한다.
|
|
268
|
+
`gaon g app admin` 이 배선을 동봉하고, `gaon g ui-kit --app admin` 은 배선이
|
|
269
|
+
없으면 멱등 보정한다(`--app` 은 이제 킷 위치가 아니라 배선만 정한다).
|
|
270
|
+
`tailwind.config.ts`·`postcss.config.js` 는 프로젝트 루트 공유이고 `content` 는
|
|
271
|
+
`apps/**` 와 `shared/**` 를 함께 훑는다. 앱이 킷을 import 하는데 배선이 없으면
|
|
272
|
+
doctor **ui-kit-wiring** 이 경고한다.
|
|
273
|
+
|
|
274
|
+
**기존 프로젝트 마이그레이션(결정 105 이전 → 이후):** 앱별 사본(`apps/<앱>/components/ui`
|
|
275
|
+
·`apps/<앱>/lib/utils.ts`)이 있으면 `gaon g ui-kit` 를 다시 실행해 `shared/` 에 킷을
|
|
276
|
+
만든 뒤, 앱 사본을 지우고 import 를 `@shared/components/ui/…` 로 바꾼다.
|
|
277
|
+
`tailwind.config.ts` 의 `content` 에 `./shared/**/*.{vue,ts}` 가 있는지도 확인한다
|
|
278
|
+
(스캐폴드 기본값엔 이미 포함).
|
|
239
279
|
|
|
240
280
|
## 정본 예시
|
|
241
281
|
|
|
@@ -290,7 +330,15 @@ async function runSearch(q: string) {
|
|
|
290
330
|
`useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
|
|
291
331
|
URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
|
|
292
332
|
틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
|
|
293
|
-
- **레이아웃을 shared 에 두지 않는다** — 앱별이
|
|
333
|
+
- **레이아웃을 shared 에 두지 않는다** — 앱별이 정상(UI 킷 §8 은 예외 — 성격
|
|
334
|
+
중립 순수 UI 라 `shared/components/ui` 프로젝트당 한 벌 · 결정 105).
|
|
335
|
+
- **UI 킷은 `@shared/components/ui/…` 로 import** — `../../../shared/...` 같은 깊은
|
|
336
|
+
상대경로 대신 `@shared` alias 로 통일한다(결정 105 · `vite.config.ts`·`tsconfig.json`
|
|
337
|
+
배선). 킷을 `apps/<앱>/components/ui` 에 복제하지 않는다.
|
|
338
|
+
- **페이지에 레이아웃 브레이크포인트 직접 사용 지양** — 폭·여백·열 수 반응형은
|
|
339
|
+
킷 블록(`PageShell` 등)이 책임진다(결정 107). `sm:flex-row`·`md:grid-cols-2` 를
|
|
340
|
+
페이지 루트에 직접 쓰면 doctor **page-layout-breakpoint** 가 **안내 경고**를 낸다
|
|
341
|
+
(강제 아님 · 킷에 없는 표현이면 그대로 둬도 된다 — 탈출구 유지).
|
|
294
342
|
- **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
|
|
295
343
|
Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
|
|
296
344
|
(결정 32·46). rename 후 컨트롤러 `this.render('...')` 키도 맞춘다.
|
|
@@ -313,4 +361,7 @@ async function runSearch(q: string) {
|
|
|
313
361
|
| 결정 75 | shadcn 식 UI 킷(`gaon g ui-kit` · 복사-소유 · Vue 3 신작 · 외부 런타임 의존 0) |
|
|
314
362
|
| 결정 76 | 멀티앱 UI 킷 배선 자동화(`g app`·`g ui-kit --app` 이 앱별 Tailwind 배선 동봉·멱등 보정 · doctor ui-kit-wiring) |
|
|
315
363
|
| 결정 96 | 앱 내부 이동 = `Link`(선언적)/`router.visit`(프로그램적) · 내부 경로 일반 `<a>` 금지(풀 리로드) · `Link` 재수출 · doctor internal-anchor |
|
|
364
|
+
| 결정 105 | UI 킷 shared 이전(`shared/components/ui` 프로젝트당 한 벌 · `@shared` alias · 결정 75 개정) |
|
|
365
|
+
| 결정 106 | 최소 4블록(PageShell·PageHeader·EmptyState·Pagination · 성격 중립) |
|
|
366
|
+
| 결정 107 | 반응형은 킷 책임(페이지 레이아웃 브레이크포인트 지양 · doctor page-layout-breakpoint 안내 경고 · 터치 44px·폰트 최소 크기 토큰) |
|
|
316
367
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
@@ -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')
|
|
@@ -155,12 +166,13 @@ redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드
|
|
|
155
166
|
|
|
156
167
|
```ts
|
|
157
168
|
// 로그인 폼 — 제출은 Inertia SPA 방식, 서버는 redirect 로 답한다.
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
169
|
+
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
170
|
+
const props = pageProps<'web:session#new'>()
|
|
171
|
+
const form = useForm({ email: '', password: '', _csrf: props.csrf })
|
|
172
|
+
// <form @submit.prevent="form.post('/session')"> · 실패 시 {{ props.error }} 가 반응형으로 갱신
|
|
161
173
|
|
|
162
174
|
// HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로 보낸다.
|
|
163
|
-
router.delete('/session', { headers: { 'x-csrf-token': csrf } })
|
|
175
|
+
router.delete('/session', { headers: { 'x-csrf-token': props.csrf } })
|
|
164
176
|
```
|
|
165
177
|
|
|
166
178
|
`?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
|
|
@@ -332,4 +344,5 @@ export default controller({
|
|
|
332
344
|
| 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
|
|
333
345
|
| 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
|
|
334
346
|
| 결정 95 (W4) | 폼 모양 2종 — 스키마 파생 `Model.form`(검증) vs 애드혹 `{ _row }`(타입만) · 라우트 파라미터는 둘 다 자동 병합 |
|
|
347
|
+
| 결정 104 | `Model.form.pick('a','b')` = 검증되는 부분 폼(결정 95 회부 종결) · 폼 변형은 pick 하나(omit/extend/merge 없음) |
|
|
335
348
|
| E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
|