📐 実装規約
⚡ 1. Server Action 戻り値 / エラー伝達
すべての Server Action は ActionResult<T> を return する。throw は「想定外(DB ダウン等)」のみで、それは error.tsx が受ける。
flowchart TD
Submit["📝 フォーム submit"]:::input
Action["⚡ Server Action 呼び出し"]:::step
Guard{"🔐 requireSession()"}:::guard
Validate{"🛡️ Zod safeParse"}:::guard
DB["🗄️ DB 操作 + revalidatePath"]:::step
OK["✅ ok: data"]:::ok
Err1["❌ err: UNAUTHORIZED"]:::err
Err2["❌ err: VALIDATION
+ fieldErrors"]:::err Client["🚀 router.push(...)"]:::client ClientErr["📛 applyActionErrors
→ setError"]:::client Submit --> Action --> Guard Guard -->|未認証| Err1 Guard -->|OK| Validate Validate -->|失敗| Err2 Validate -->|成功| DB --> OK --> Client Err1 --> ClientErr Err2 --> ClientErr classDef input fill:#eef3f8,stroke:#6b9bc7,color:#1a2533 classDef step fill:#f0f6fc,stroke:#1e5a8e,color:#1a2533 classDef guard fill:#fff3e6,stroke:#c47a14,color:#1a2533 classDef ok fill:#e8f3ec,stroke:#2f7a4a,color:#1a2533 classDef err fill:#fbe9e9,stroke:#b13838,color:#1a2533 classDef client fill:#eef3f8,stroke:#6b9bc7,color:#1a2533
+ fieldErrors"]:::err Client["🚀 router.push(...)"]:::client ClientErr["📛 applyActionErrors
→ setError"]:::client Submit --> Action --> Guard Guard -->|未認証| Err1 Guard -->|OK| Validate Validate -->|失敗| Err2 Validate -->|成功| DB --> OK --> Client Err1 --> ClientErr Err2 --> ClientErr classDef input fill:#eef3f8,stroke:#6b9bc7,color:#1a2533 classDef step fill:#f0f6fc,stroke:#1e5a8e,color:#1a2533 classDef guard fill:#fff3e6,stroke:#c47a14,color:#1a2533 classDef ok fill:#e8f3ec,stroke:#2f7a4a,color:#1a2533 classDef err fill:#fbe9e9,stroke:#b13838,color:#1a2533 classDef client fill:#eef3f8,stroke:#6b9bc7,color:#1a2533
shared/lib/action.ts
export type ErrorCode =
| 'VALIDATION' // Zod 失敗、fieldErrors 同梱
| 'UNAUTHORIZED' // 未ログイン or 権限なし
| 'NOT_FOUND' // 対象リソースが他人 or 存在しない
| 'RATE_LIMITED' // Upstash で弾かれた
| 'CONFLICT' // UNIQUE 違反など
| 'INTERNAL'; // 想定外(基本これに落ちる前に throw)
export type ActionError = {
code: ErrorCode;
message: string; // UI 言語は日本語のみ
fieldErrors?: Record<string, string>; // RHF setError に流す
};
export type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; error: ActionError };
export const okResult = <T>(data: T): ActionResult<T> => ({ ok: true, data });
export const errResult = (
code: ErrorCode,
message: string,
fieldErrors?: Record<string, string>,
): ActionResult<never> => ({ ok: false, error: { code, message, fieldErrors } });
📋 規約
-
成功後のリダイレクトは Action 内では行わない
okResult({ id })を return し、クライアント側でrouter.pushする。 -
各 Action 冒頭で
requireSession()を呼ぶ未認証なら
UNAUTHORIZEDのActionResultを即 return(§2)。 -
revalidatePath/revalidateTagの呼び出しは Action 内に残すcache 無効化は Action の責務。
-
例外的に Next.js の
redirect()を呼ぶ場合try/catch で握りつぶさないよう
isRedirectError(e)を必ずチェック。
🔐 2. 認証ガード実装(requireSession)
責務分担は architecture.md §4 を参照。ここでは実装ヘルパのみ。
shared/lib/auth-guard.ts
import { auth } from '@/modules/auth';
import { headers } from 'next/headers';
import { errResult, okResult, type ActionResult } from './action';
type AuthedSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
export async function requireSession(): Promise<ActionResult<AuthedSession>> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return errResult('UNAUTHORIZED', '認証が必要です');
return okResult(session);
}
各 Action での使い方:
'use server';
export async function createBean(input: BeanInput): Promise<ActionResult<{ id: string }>> {
const auth = await requireSession();
if (!auth.ok) return auth;
const parsed = createBeanSchema.safeParse(input);
if (!parsed.success) return errResult('VALIDATION', '入力エラー', flattenZodErrors(parsed.error));
// ... DB INSERT、revalidatePath('/beans') 等
return okResult({ id: newId });
}
📝 3. フォーム実装
- React Hook Form +
zodResolver+ HeroUI controlled component の組み合わせ - 共通化するのは
applyActionErrors(result, setError)ヘルパ 1 つのみ(useFormAction等のフックは作らない) - Zod schema は client / server 両側で同一のものを使う(
modules/*/validations.tsから import)
shared/lib/form.ts
import type { UseFormSetError, FieldValues, Path } from 'react-hook-form';
import type { ActionError, ActionResult } from './action';
export function applyActionErrors<T, F extends FieldValues>(
result: ActionResult<T>,
setError: UseFormSetError<F>,
): result is { ok: false; error: ActionError } {
if (result.ok) return false;
const { code, message, fieldErrors } = result.error;
if (code === 'VALIDATION' && fieldErrors) {
for (const [field, msg] of Object.entries(fieldErrors)) {
setError(field as Path<F>, { message: msg });
}
} else {
setError('root', { message });
}
return true;
}
modules/bean/components/bean-form.tsx
'use client';
export function BeanForm() {
const router = useRouter();
const { register, handleSubmit, setError, formState: { errors, isSubmitting } } = useForm<BeanInput>({
resolver: zodResolver(createBeanSchema),
});
const onSubmit = handleSubmit(async (data) => {
const result = await createBean(data);
if (applyActionErrors(result, setError)) return;
router.push(`/beans/${result.data.id}`);
});
return (
<form onSubmit={onSubmit}>
<Input {...register('name')} errorMessage={errors.name?.message} />
{/* ... */}
</form>
);
}
📄 4. ページング / 絞り込み
| 一覧 | 方式 |
|---|---|
| Bean 一覧 | ページングなし、全件取得・created_at DESC。archived 表示切替トグルのみ |
| Brew 一覧 | offset ベース(?page=N&pageSize=20)+ ★評価 / 日付範囲 / Bean フィルタ |
- テキスト検索は Phase 1 では実装しない(requirements.md §2.3)
- ページサイズ初期値
20、HeroUIPaginationコンポーネントと組む
🔑 5. 主キー / URL ID
- 全テーブルの PK は
text型の cuid2(@paralleldrive/cuid2) - URL にもそのまま出す(
/beans/{cuid2}、/brews/{cuid2}) - better-auth の
user.idもstringなので、FK 型がすべてtextで揃う - ソートは
idではなくcreated_at/brewed_atを使う(cuid2 は時間順性なし)