# 機能設計書 9-エントリ保存（永続化）

## 概要

本ドキュメントは、Decap CMSのエントリ保存（永続化）機能の設計を記載する。エントリのバリデーション後、シリアライズしてバックエンドに保存する処理であり、i18nデータのシリアライズも含む。

### 本機能の処理概要

本機能は、編集中のドラフトエントリをバックエンドのGitリポジトリに永続化する。保存前にバリデーションエラーチェックを行い、フィールド値をシリアライズし、メディアファイルのアセットプロキシを収集した上で、バックエンドの`persistEntry()`を呼び出す。保存成功後はエントリ一覧の再読み込みやスラッグ変更時のリダイレクトも行う。

**業務上の目的・背景**：CMSの核心的な機能として、ユーザーが編集したコンテンツをGitリポジトリに保存（コミット＆プッシュ）する必要がある。保存前のバリデーション、カスタムシリアライザーの適用、i18nデータのマルチロケールシリアライズ、ドラフトメディアファイルの同時保存など、複雑な前処理が必要である。

**機能の利用シーン**：エントリ編集画面やエントリ新規作成画面の「保存」ボタン押下時に実行される。

**主要な処理内容**：
1. バリデーションエラーチェック（fieldsErrors）
2. フィールド値のシリアライズ（`serializeValues`）
3. i18nデータのシリアライズ（`serializeI18n`）
4. ドラフトメディアファイルのアセットプロキシ収集（`getMediaAssets`）
5. バックエンドへの永続化（`backend.persistEntry`）
6. 成功通知の表示
7. メディアライブラリの再読み込み（メディアファイルがある場合）
8. ネストコレクションのエントリ一覧再読み込み
9. スラッグ変更時のエントリ再読み込みとURL遷移

**関連システム・外部連携**：各Gitバックエンド（GitHub, GitLab等）のコミット/プッシュAPI、メディアライブラリ。

**権限による制御**：認証済みユーザーのみがエントリを保存できる。Open Authoringモードではフォークリポジトリへのコミットとなる。

## 関連画面

| 画面No | 画面名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 6 | エントリ編集画面 | 主画面 | バリデーション後のエントリ保存処理。persistEntryアクションによるバックエンドへの永続化 |
| 7 | エントリ新規作成画面 | 主画面 | 新規エントリのバリデーション後保存。persistEntryアクションによるバックエンドへの初回保存 |

## 機能種別

CRUD操作（Create / Update）/ データ永続化

## 入力仕様

### 入力パラメータ

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| collection | Collection | Yes | 対象コレクション | - |

### 入力データソース

- Reduxストア（entryDraft: ドラフトエントリデータ、fieldsErrors: バリデーションエラー）
- Reduxストア（config: バックエンド設定）
- ドラフトメディアファイル（entry.mediaFiles）

## 出力仕様

### 出力データ

| 項目名 | 型 | 説明 |
|--------|-----|------|
| newSlug | string | バックエンドから返される新規スラッグ（新規エントリの場合） |
| 成功通知 | Notification | 'ui.toast.entrySaved' メッセージ |

### 出力先

- Gitリポジトリ（バックエンド経由）
- Reduxストア（entries状態スライス）
- 通知システム（成功/失敗メッセージ）

## 処理フロー

### 処理シーケンス

```
1. persistEntry(collection)が呼び出される
2. Reduxストアからentryドラフトと設定を取得
3. fieldsErrorsの確認
   └─ エラーあり: PRESENCEエラーの場合は必須フィールド通知を表示
   └─ Promise.reject()で早期リターン
4. バックエンドインスタンスの取得
5. ドラフトメディアファイルのアセットプロキシ収集
   └─ getMediaAssets(): draft=trueのファイルからAssetProxyを作成
6. エントリのシリアライズ
   └─ getSerializedEntry(): serializeValues + serializeI18n
7. ENTRY_PERSIST_REQUESTをディスパッチ
8. backend.persistEntry()を呼び出し
   └─ config, collection, entryDraft, assetProxies, usedSlugs を渡す
9. 成功時:
   └─ 'ui.toast.entrySaved' 通知（4000ms）
   └─ メディアファイルがある場合: loadMedia()
   └─ ENTRY_PERSIST_SUCCESSをディスパッチ
   └─ ネストコレクションの場合: loadEntries()で一覧再読み込み
   └─ スラッグ変更時: loadEntry() + navigateToEntry()
10. 失敗時:
    └─ エラー通知（'ui.toast.onFailToPersist'、8000ms）
    └─ ENTRY_PERSIST_FAILUREをディスパッチ
```

### フローチャート

```mermaid
flowchart TD
    A[persistEntry呼び出し] --> B{fieldsErrors<br/>あり?}
    B -->|Yes| C{PRESENCEエラー?}
    C -->|Yes| D[必須フィールド通知]
    C -->|No| E[Promise.reject]
    D --> E
    B -->|No| F[getMediaAssets]
    F --> G[getSerializedEntry]
    G --> H[ENTRY_PERSIST_REQUEST dispatch]
    H --> I[backend.persistEntry]
    I --> J{成功?}
    J -->|Yes| K[成功通知 4000ms]
    K --> L{メディア<br/>ファイルあり?}
    L -->|Yes| M[loadMedia]
    L -->|No| N[ENTRY_PERSIST_SUCCESS dispatch]
    M --> N
    N --> O{ネスト<br/>コレクション?}
    O -->|Yes| P[loadEntries]
    O -->|No| Q{スラッグ<br/>変更?}
    P --> Q
    Q -->|Yes| R[loadEntry + navigateToEntry]
    Q -->|No| S[完了]
    R --> S
    J -->|No| T[エラー通知 8000ms]
    T --> U[ENTRY_PERSIST_FAILURE dispatch]
```

## ビジネスルール

### 業務ルール

| ルールNo | ルール名 | 内容 | 適用条件 |
|---------|---------|------|---------|
| BR-01 | バリデーション必須 | fieldsErrorsが空でない場合は保存を拒否する | persistEntry |
| BR-02 | 必須フィールド通知 | PRESENCEタイプのバリデーションエラーがある場合は専用通知を表示 | バリデーションエラー時 |
| BR-03 | フィールドシリアライズ | レジストリに登録されたカスタムシリアライザーを適用する | persistEntry |
| BR-04 | i18nシリアライズ | i18n対応コレクションではi18nデータも合わせてシリアライズする | i18n有効時 |
| BR-05 | ドラフトメディア収集 | draft=trueのメディアファイルのみをアセットプロキシとして収集する | メディアファイルが添付されている場合 |
| BR-06 | スラッグ重複チェック | usedSlugsを渡してバックエンドでスラッグの重複を検出する | persistEntry |
| BR-07 | ネストコレクション再読み込み | ネストコレクションの場合は保存後にエントリ一覧を再読み込みしてツリーを更新 | nested設定のコレクション |
| BR-08 | スラッグ変更リダイレクト | 保存によりスラッグが変わった場合、新しいスラッグのURLに遷移する | 新規エントリまたはスラッグフィールド変更時 |

### 計算ロジック

特になし。

## データベース操作仕様

### 操作別データベース影響一覧

| 操作 | 対象 | 操作種別 | 概要 |
|-----|------|---------|------|
| エントリ保存 | Gitリポジトリ | CREATE/UPDATE | ファイルのコミット＆プッシュ |
| メディア保存 | Gitリポジトリ | CREATE | メディアファイルのコミット＆プッシュ |
| 状態更新（開始） | Reduxストア(entries) | UPDATE | ENTRY_PERSIST_REQUEST |
| 状態更新（成功） | Reduxストア(entries) | UPDATE | ENTRY_PERSIST_SUCCESS |
| 状態更新（失敗） | Reduxストア(entries) | UPDATE | ENTRY_PERSIST_FAILURE |

## エラー処理

### エラーケース一覧

| エラーコード | エラー種別 | 発生条件 | 対処方法 |
|------------|----------|---------|---------|
| - | バリデーションエラー | fieldsErrorsが空でない | Promise.reject、必須フィールドエラー時は'ui.toast.missingRequiredField'通知 |
| ENTRY_PERSIST_FAILURE | 保存エラー | backend.persistEntry()がエラーを返す | 'ui.toast.onFailToPersist'通知（8000ms）、コンソールにエラー出力 |

### リトライ仕様

保存処理のリトライ機構は実装されていない。ユーザーによる再保存操作が必要。

## トランザクション仕様

バックエンドのpersistEntry内でGitコミットとプッシュが行われる。これはバックエンド固有のトランザクション制御に依存する。メディアファイルとエントリファイルは同一コミットに含まれる。

## パフォーマンス要件

- 保存操作はバックエンドAPI呼び出しを含むため、ネットワーク速度に依存
- 成功通知のdismissAfterは4000ms（4秒）
- エラー通知のdismissAfterは8000ms（8秒）
- メディアファイルが多い場合、アセットプロキシの収集とアップロードに時間がかかる可能性がある

## セキュリティ考慮事項

- 保存操作は認証済みバックエンドAPIを介して行われる
- usedSlugsによるスラッグ重複チェックでコンテンツの意図しない上書きを防止

## 備考

- getSerializedEntry関数はserializeValuesとserializeI18nの2つのシリアライズ処理を組み合わせる
- AssetProxyは`createAssetProxy`で生成され、path, file, url, fieldの情報を持つ
- ENTRY_PERSIST_SUCCESSアクションには新規スラッグ（newSlug）が含まれ、URLの更新に使用される

---

## コードリーディングガイド

本機能を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: データ構造を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | AssetProxy.ts | `packages/decap-cms-core/src/valueObjects/AssetProxy.ts` | AssetProxy型の定義 |
| 1-2 | types/redux.ts | `packages/decap-cms-core/src/types/redux.ts` | EntryMap型、Collection型 |

**読解のコツ**: persistEntry内ではentryDraftの状態から直接エントリデータを取得する。entryDraft.get('entry')がEntryMapを返し、entryDraft.get('fieldsErrors')がバリデーションエラーマップを返す。

#### Step 2: エントリーポイントを理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | entries.ts (actions) | `packages/decap-cms-core/src/actions/entries.ts` | persistEntry関数 |

**主要処理フロー**:
1. **893-919行目**: バリデーションエラーチェックと早期リターン
2. **921-928行目**: バックエンド取得、エントリ取得、メディアアセット収集
3. **927行目**: getSerializedEntry - シリアライズ処理
4. **929行目**: ENTRY_PERSIST_REQUESTディスパッチ
5. **930-977行目**: backend.persistEntry呼び出しと成功/失敗ハンドリング
6. **938-960行目**: 成功時処理（通知、メディア再読み込み、エントリ再読み込み、ナビゲーション）

#### Step 3: シリアライズ処理を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | entries.ts (actions) | `packages/decap-cms-core/src/actions/entries.ts` | getSerializedEntry, getMediaAssets |

**主要処理フロー**:
- **873-891行目**: getSerializedEntry - serializeValuesとserializeI18nの組み合わせ
- **857-871行目**: getMediaAssets - draft=trueのファイルからAssetProxy配列を作成

### プログラム呼び出し階層図

```
persistEntry(collection)
    |
    +-- state.entryDraft.get('fieldsErrors')  [チェック]
    |       +-- [エラーあり] addNotification('missingRequiredField')
    |       +-- Promise.reject()
    |
    +-- currentBackend(config)
    +-- entryDraft.get('entry')
    +-- getMediaAssets({entry})
    |       +-- entry.get('mediaFiles').filter(draft=true)
    |       +-- createAssetProxy(...)
    +-- getSerializedEntry(collection, entry)
    |       +-- selectFields(collection, slug)
    |       +-- serializeValues(data, fields)
    |       +-- serializeI18n(collection, entry, serializeData)
    +-- entryPersisting()  [dispatch]
    +-- backend.persistEntry({config, collection, entryDraft, assetProxies, usedSlugs})
            |
            +-- [成功]
            |       +-- addNotification('entrySaved')  [dispatch]
            |       +-- loadMedia()  [dispatch]  (条件付き)
            |       +-- entryPersisted()  [dispatch]
            |       +-- loadEntries()  [dispatch]  (ネスト時)
            |       +-- loadEntry() + navigateToEntry()  (スラッグ変更時)
            |
            +-- [失敗]
                    +-- addNotification('onFailToPersist')  [dispatch]
                    +-- entryPersistFail()  [dispatch]
```

### データフロー図

```
[入力]                          [処理]                              [出力]

entryDraft ----------------> fieldsErrors チェック
(Reduxストア)                        |
                            getMediaAssets()
                                    |
                            getSerializedEntry()
                            serializeValues()
                            serializeI18n()
                                    |
                            backend.persistEntry() -------> Gitリポジトリ
                                    |                       (コミット＆プッシュ)
                            [成功/失敗]
                                    |
                            Reduxストア更新 -----------> entries状態
                            通知表示 ------------------> UIトースト
                            URL遷移 ------------------> ブラウザURL
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| entries.ts (actions) | `packages/decap-cms-core/src/actions/entries.ts` | ソース | persistEntry、getSerializedEntry、getMediaAssets |
| serializeEntryValues.ts | `packages/decap-cms-core/src/lib/serializeEntryValues.ts` | ソース | serializeValues関数 |
| i18n.ts | `packages/decap-cms-core/src/lib/i18n.ts` | ソース | serializeI18n, hasI18n |
| AssetProxy.ts | `packages/decap-cms-core/src/valueObjects/AssetProxy.ts` | ソース | createAssetProxy |
| backend.ts | `packages/decap-cms-core/src/backend.ts` | ソース | persistEntry（バックエンド実装） |
| history.ts | `packages/decap-cms-core/src/routing/history.ts` | ソース | navigateToEntry |
| validationErrorTypes.ts | `packages/decap-cms-core/src/constants/validationErrorTypes.ts` | ソース | ValidationErrorTypes.PRESENCE |
| mediaLibrary.ts (actions) | `packages/decap-cms-core/src/actions/mediaLibrary.ts` | ソース | loadMedia |
