コンテンツにスキップ
Documents for MorningStatusApp
Esc
移動開く⌘Jプレビュー
このページの内容

データストレージ移行計画

背景と問題

現状の課題

現在のアーキテクチャでは data/members.json をプロジェクトリポジトリ内で管理し、Next.js API Route (app/api/members/route.ts) から fs.writeFileSync で書き込んでいる。

Vercel のサーバーレス関数は /var/task(プロジェクトルート)が 読み取り専用 となっており、fs.writeFileSyncEROFS: read-only file system エラーで失敗する。

その結果、ローカル開発環境では動作するが、本番(Vercel デプロイ済み環境)では編集内容を保存できない。

現在の暫定対応として、書き込みエラー時に HTTP 503 を返してユーザーに通知する処理を実装しているが、根本的な解決には外部ストレージへの移行が必要である。

関連情報

  • 開発ノート 実装 #7「Vercel 本番環境でのファイル書き込み制限」
  • GitHub Issue #69「編集保存機能改善」

現在のアーキテクチャ

┌─────────────────────────────────────────────┐
│ GitHub リポジトリ                            │
│  data/members.json  ←── git commit          │
│         │                    ▲              │
│         │              GitHub Actions       │
│         │              sync-members.yml     │
│         │              (毎日 JST 09:00)      │
└─────────┼────────────────────┼──────────────┘
          │ (静的ファイル)      │ (コミット)
          ▼                    │
┌─────────────────────┐   scripts/sync-status.ts
│ Vercel デプロイ      │
│  Next.js App Router │
│  /api/members (GET) │ ← JSON 読み取り ✅
│  /api/members (PUT) │ ← JSON 書き込み ❌ (EROFS)
└─────────────────────┘

移行先オプションの比較

オプション A: Vercel Blob(推奨)

@vercel/blob パッケージを使い、members.json の内容を Blob ストレージに保存する。

移行後のデータフロー:

Vercel Blob
  members.json (blob)

        ├── GET  /api/members  → blob.downloadUrl から JSON 取得
        ├── PUT  /api/members  → JSON を上書き保存
        └── sync-status.ts    → Blob を上書き更新
観点 評価
移行コスト 低い(JSON 形式を維持、API 変更が最小)
既存データ形式の互換性 ◎ そのまま利用可能
同期スクリプトへの影響 小(@vercel/blob SDK でアップロードに変更)
インフラ追加 Vercel Blob のみ(同一プラットフォーム)
コスト 無料枠: 5 GB ストレージ・1 GB 転送/月
バージョン管理 なし(git での日次コミット履歴は失われる)

バージョン管理喪失のリスクと代替手段

現在の data/members.json は GitHub Actions による毎日の自動コミットで変更履歴が git に蓄積されており、特定日時へのロールバックや変更追跡が可能である。Blob 移行後はこれらが失われる。

失われるもの:

  • git での日次コミット履歴(ロールバック手段・変更追跡)

代替手段:

  • Vercel Blob は上書き保存のみで、標準的なバージョニング機能は持たない(@vercel/bloblist() で同名ファイルの過去バージョンは取得できない)
  • 重要な変更前には手動で JSON のスナップショットを別ファイル名として Blob に保存する運用を補完策として採用する

「重要な変更」の例:

  • バグ修正による一括データ更新(statusHistory の整形など)
  • メンバーデータ構造の変更(新フィールド追加など)
  • 移行フェーズ実施前(Blob 移行の各フェーズ開始前)

スナップショット管理方針:

  • ファイル名に ISO 日時を含める(例: members-backup-YYYYMMDDTHHmm.json
  • 直近 3 件のスナップショットを保持する。新たなスナップショット作成後、4件目以降の古いものを del() で削除する
  • 変更追跡が必要になった場合は、将来的にオプション C(Vercel Postgres)への再移行を検討する

必要な環境変数:

変数名 用途 取得タイミング
BLOB_READ_WRITE_TOKEN Blob への読み書き権限 Vercel ダッシュボードで Blob ストア作成時に自動生成
MEMBERS_BLOB_URL members.json の Blob URL フェーズ 4 の初期データ移行スクリプト実行後に取得

注意: MEMBERS_BLOB_URL はフェーズ 4 完了後でないと確定しない。フェーズ 2・3 の実装時はプレースホルダーとして設定し、フェーズ 4 実行後に正式な URL に差し替えること。


オプション B: Vercel KV(Redis)

@vercel/kv を使い、メンバーを member:{id} というキーで個別に管理する。

観点 評価
移行コスト 中(データ構造の変更が必要)
既存データ形式の互換性 △ 全件取得に SCAN が必要で複雑
同期スクリプトへの影響 中(各メンバーを個別に SET)
インフラ追加 Vercel KV のみ
コスト 無料枠: 256 MB

「全メンバー一覧取得」のパターンが多いこのアプリには不向き。


オプション C: Vercel Postgres(Neon)

PostgreSQL を使ってメンバーデータをリレーショナルに管理する。

観点 評価
移行コスト 高い(スキーマ設計・Prisma 導入が必要)
既存データ形式の互換性 △ JSON → テーブル変換が必要
同期スクリプトへの影響 大(SQL INSERT/UPDATE に変更)
拡張性 ◎ 将来的な機能拡張に対応しやすい
コスト 無料枠: 0.5 GB

現在の規模(47 名)には過剰。将来的にディスコグラフィー等(Issue #56)を追加する場合は有力候補。


オプション D: GitHub API 経由でのコミット

編集 API が GitHub Contents API を呼び出してリポジトリに直接コミットする方式。

観点 評価
移行コスト 中(GitHub Personal Access Token が必要)
既存データ形式の互換性
リアルタイム性 × 保存後に再デプロイが必要
レート制限 あり(GitHub API)

保存後すぐに画面に反映されないため UX が悪く、このアプリには不向き。


推奨案: オプション A(Vercel Blob)

選定理由

  1. 移行コストが最小: JSON ファイルをそのまま Blob に置き換えるだけで、データ構造・型定義の変更が不要。
  2. 同期スクリプトとの親和性が高い: sync-status.ts は現在 fs.writeFileSync でファイルを更新しているが、put() 関数に変更するだけで対応できる。
  3. 同一プラットフォーム: Vercel プロジェクトと統合されており、追加インフラ管理が不要。
  4. 無料枠で十分: 現在のデータ量(members.json は約 200 KB 程度)は無料枠の範囲内。

実装計画

フェーズ 1: Vercel Blob の設定

  1. Vercel ダッシュボードで Blob ストア(morning-status-app-store 等)を作成する。
  2. BLOB_READ_WRITE_TOKEN 環境変数を Vercel プロジェクトと GitHub Actions Secrets に登録する。
  3. パッケージを追加する:
    bun add @vercel/blob

フェーズ 2: API Route の移行

app/api/members/route.tsfs.readFileSync / fs.writeFileSync@vercel/blob の API に置き換える。

GET(読み取り):

export async function GET() {
  const res = await fetch(process.env.MEMBERS_BLOB_URL!);
  const data = await res.json();
  return NextResponse.json(data);
}

PUT(書き込み):

import { put } from "@vercel/blob";

export async function PUT(request: Request) {
  const updatedMember: Member = await request.json();
  // 既存データを取得して更新し、Blob に PUT
  const res = await fetch(process.env.MEMBERS_BLOB_URL!);
  const data = await res.json();
  // ... 更新処理 ...
  await put("members.json", JSON.stringify(data, null, 2), {
    access: "public",
    contentType: "application/json",
    addRandomSuffix: false,
  });
  return NextResponse.json({ message: "Member updated successfully", member: updatedMember });
}

注意: put() はデフォルトで新しい URL を発行する。固定 URL を使いたい場合は addRandomSuffix: false を指定するか、URL を環境変数で管理する。Vercel Blob の copy()del() を組み合わせる方式も検討すること。

フェーズ 3: 同期スクリプトの移行

scripts/sync-status.tsfs.writeFileSync を Blob の put() に置き換える。

import { put } from "@vercel/blob";
// ...
await put("members.json", JSON.stringify(data, null, 2), {
  access: "public",
  contentType: "application/json",
  addRandomSuffix: false, // 固定ファイル名
  token: process.env.BLOB_READ_WRITE_TOKEN,
});

GitHub Actions の sync-members.yml では git add / commit の代わりに Blob への書き込みのみで完結するため、contents: write パーミッションが不要になる。

data/members.json の凍結方針

移行後、git 上の data/members.json移行時点の内容で凍結する。

  • 同期スクリプトは Blob のみを更新し、git へのコミットは行わない
  • data/members.json はビルド時の SSG 初期データとしてのみ使用する(フェーズ 5 で revalidatePath / ISR を導入することで、本番データは Blob を正として扱う)
  • 凍結後は data/members.json への手動コミットは行わないこと(Blob との乖離が拡大し、次回ビルド時に古いデータが SSG ページを上書きするリスクがある)

補足: フェーズ 5(SSG 対応)を完了させることで、一覧・詳細ページともに Blob のデータが正となり、git の JSON との乖離が問題にならなくなる。フェーズ 5 未実施のまま運用する場合は、詳細ページのデータが徐々に古くなる点に注意すること。

フェーズ 4: 初期データ移行

// 一度だけ実行するスクリプト(scripts/migrate-to-blob.ts)
import { put } from "@vercel/blob";
import fs from "fs";
import path from "path";

const data = fs.readFileSync(path.join(process.cwd(), "data", "members.json"), "utf-8");
const result = await put("members.json", data, {
  access: "public",
  contentType: "application/json",
  addRandomSuffix: false,
  token: process.env.BLOB_READ_WRITE_TOKEN,
});
console.log("Blob URL:", result.url);
// → この URL を MEMBERS_BLOB_URL 環境変数に設定する

フェーズ 5: 静的ビルドの対応(推奨)

現在 generateStaticParams で SSG しているメンバー詳細ページについて、Blob からデータを取得するように app/members/[id]/page.tsx のデータ取得部分を変更する。

推奨理由: Blob 移行後、同期スクリプトは Blob のみを更新するため、SSG で生成された詳細ページは古いデータのまま固定される。一覧ページ(/api/members 経由でリアルタイムデータ)と詳細ページのデータが食い違う状態を防ぐため、フェーズ 1〜4 と合わせて対応することを推奨する。

ビルド時は data/members.json を引き続き使用し、ランタイム(API)のみ Blob を参照する ハイブリッド方式 も可能。この場合、Blob 更新後に Vercel の revalidatePath でキャッシュを無効化する。

推奨実装:

  • 全ページ再検証(対象IDが不明な場合): PUT /api/members の処理完了後に revalidatePath('/members/[id]', 'page') を呼び出す(Next.js 14+、該当するすべての /members/* ページを再検証)
  • 単一ページ再検証(対象IDが判明している場合): revalidatePath(`/members/${updatedMember.id}`) を呼び出す(更新対象のページのみを再検証するため効率的)
  • または app/members/[id]/page.tsxexport const revalidate = 3600 (ISR) を設定し、1 時間ごとに自動再生成する

移行後のアーキテクチャ

┌─────────────────────────────────────────────┐
│ GitHub リポジトリ                            │
│  data/members.json  (ビルド用・参照のみ)  │
│                                             │
│  GitHub Actions sync-members.yml            │
│    → scripts/sync-status.ts                 │
│      → @vercel/blob put()                   │
└────────────────────────┬────────────────────┘
                         │ (Blob 書き込み)

              ┌─────────────────┐
              │  Vercel Blob    │
              │  members.json   │
              └────────┬────────┘
                       │ (読み書き)
              ┌────────▼────────────┐
              │  Vercel デプロイ    │
              │  /api/members (GET) │ ✅
              │  /api/members (PUT) │ ✅
              └─────────────────────┘

想定作業量

フェーズ 作業内容 難易度 優先度
1 Vercel Blob 設定・パッケージ追加 必須
2 API Route 移行 必須
3 同期スクリプト移行 必須
4 初期データ移行スクリプト実行 必須
5 静的ビルド対応(SSG + revalidate) 推奨

フェーズ 1〜4 の実装は 1 日以内での完了を見込む。


参考リンク

このページは役に立ちましたか?