T-CREATOR

Pinia ストアスキーマの変更管理:バージョン付与・マイグレーション・互換ポリシー

Pinia ストアスキーマの変更管理:バージョン付与・マイグレーション・互換ポリシー

Vue.js の状態管理ライブラリ「Pinia」を使っていると、アプリケーションの成長とともにストアのデータ構造も進化していきますよね。しかし、永続化されたストアデータが古い形式のままだと、アプリケーションがエラーを起こしてしまうことがあります。

そこで重要になるのが「スキーマのバージョン管理とマイグレーション」です。本記事では、Pinia ストアのスキーマ変更を安全に管理する方法について、バージョン付与の戦略からマイグレーション実装、互換性ポリシーまで、実践的な手法を詳しく解説していきます。

背景

Pinia ストアの永続化とスキーマの関係

Pinia は Vue 3 の公式状態管理ライブラリとして、シンプルで型安全な API を提供しています。多くのアプリケーションでは、ユーザーの設定や認証情報などを localStorage や IndexedDB に永続化しますが、この永続化データには「スキーマ」という概念が存在するんです。

スキーマとは、ストアデータの構造や型定義のこと。アプリケーションのバージョンアップに伴い、新しい機能を追加したり、データ構造を最適化したりする際には、このスキーマも変更する必要があります。

typescript// 初期バージョンのストアスキーマ例
interface UserStoreV1 {
  id: string;
  name: string;
  email: string;
}

このシンプルな構造から始まったストアも、アプリの成長とともに複雑になっていくことは避けられません。

typescript// バージョンアップ後のストアスキーマ例
interface UserStoreV2 {
  id: string;
  profile: {
    firstName: string; // name を分割
    lastName: string;
    email: string;
  };
  preferences: {
    // 新機能追加
    theme: 'light' | 'dark';
    language: string;
  };
  metadata: {
    createdAt: number;
    updatedAt: number;
  };
}

スキーマ変更が必要になる典型的なシナリオ

実際の開発現場では、以下のようなケースでスキーマ変更が発生します。

#シナリオ具体例影響度
1機能追加新しいユーザー設定項目の追加★★☆
2データ構造の最適化フラットな構造からネスト構造への変更★★★
3型の変更string から number への型変更★★★
4プロパティ名の変更より明確な命名への変更★★★
5不要なデータの削除非推奨機能のデータ削除★☆☆

以下の図は、アプリケーションのライフサイクルにおけるスキーマ変更の流れを示しています。

mermaidflowchart TB
  v1["Ver 1.0<br/>初期スキーマ"] -->|機能追加| v2["Ver 2.0<br/>拡張スキーマ"]
  v2 -->|構造最適化| v3["Ver 3.0<br/>リファクタ後"]
  v3 -->|型安全性向上| v4["Ver 4.0<br/>厳格な型定義"]

  style v1 fill:#e1f5ff
  style v2 fill:#b3e5fc
  style v3 fill:#81d4fa
  style v4 fill:#4fc3f7

図で理解できる要点:

  • スキーマは段階的に進化していく
  • 各バージョンで異なる変更理由がある
  • 後方互換性を考慮した移行が必要

課題

スキーマ変更に伴う互換性問題

Pinia ストアのスキーマを変更する際、最も深刻な問題は「既存ユーザーのデータとの互換性」です。新しいバージョンのアプリケーションが古いスキーマのデータを読み込もうとすると、以下のようなエラーが発生してしまいます。

typescript// エラーケース:古いデータを新しいスキーマで読み込む
const oldData = {
  id: '123',
  name: 'John Doe',
  email: 'john@example.com',
};

// 新しいコードでアクセス
console.log(oldData.profile.firstName); // TypeError: Cannot read property 'firstName' of undefined

このエラーは、以下のような実際の問題を引き起こします:

エラーコード: TypeError: Cannot read property 'firstName' of undefined

発生条件:

  • ユーザーが以前のバージョンでデータを保存していた
  • アプリを最新版にアップデート
  • 永続化されたデータを読み込もうとした時点

影響範囲:

  • アプリケーションのクラッシュ
  • ユーザーデータの喪失
  • ユーザー体験の著しい低下

バージョン管理の欠如による問題

多くのプロジェクトでは、スキーマにバージョン情報を持たせていないため、現在のデータがどのスキーマに基づいているのか判別できません。

typescript// バージョン情報がない状態(問題あり)
interface UserStore {
  id: string;
  name: string; // これはいつのバージョン?
  // バージョン情報がないため判別不可能
}

この状態では、以下の問題が発生します:

#問題点詳細対処の難易度
1データ形式の特定不可どのバージョンのスキーマか判断できない★★★
2マイグレーション実行判断不可マイグレーションが必要か判定できない★★★
3ロールバック困難問題発生時に前バージョンに戻せない★★☆
4デバッグの複雑化不具合の原因特定に時間がかかる★★☆

以下の図は、バージョン管理がない場合の問題発生フローを示しています。

mermaidflowchart LR
  user["ユーザー"] -->|古いデータ保存| storage[("LocalStorage")]
  storage -->|データ読込| app["新バージョンアプリ"]
  app -->|スキーマ不一致| error["エラー発生"]
  error -->|クラッシュ| crash["アプリ停止"]

  style error fill:#ffcdd2
  style crash fill:#f44336,color:#fff

図で理解できる要点:

  • 古いデータと新しいアプリの間に互換性チェックがない
  • エラーが発生してもリカバリー手段がない
  • ユーザー体験が大きく損なわれる

段階的なマイグレーション戦略の不在

スキーマ変更を一度に行おうとすると、複雑さが増し、バグの温床となります。特に以下のような状況では、計画的な移行戦略が必要です。

typescript// 一度に大量の変更を行う(リスクが高い)
function migrateAll(oldData: any): NewSchema {
  // 複数の変更を同時に実行
  return {
    id: oldData.id,
    profile: parseProfile(oldData.name), // 変更1
    preferences: createDefaults(), // 変更2
    metadata: generateMetadata(), // 変更3
    settings: transformSettings(oldData), // 変更4
  };
  // バグが発生した場合、どの変更が原因か特定困難
}

このアプローチの問題点:

  • デバッグが困難
  • テストケースが爆発的に増加
  • ロールバックが複雑
  • チーム開発での混乱

解決策

スキーマバージョン付与の実装

スキーマ変更を安全に管理するための第一歩は、各スキーマにバージョン番号を付与することです。これにより、現在のデータがどのスキーマに基づいているかを明確に識別できるようになります。

バージョン情報の型定義

まず、バージョン情報を含む基本的な型定義を作成しましょう。

typescript// バージョン管理用の基本型定義
interface VersionedSchema {
  _version: number; // スキーマバージョン番号
  _createdAt?: number; // 作成日時(オプション)
  _updatedAt?: number; // 更新日時(オプション)
}

この基本型を拡張して、各バージョンのスキーマを定義します。

typescript// バージョン1のスキーマ定義
interface UserStoreSchemaV1 extends VersionedSchema {
  _version: 1;
  id: string;
  name: string;
  email: string;
}
typescript// バージョン2のスキーマ定義(構造を改善)
interface UserStoreSchemaV2 extends VersionedSchema {
  _version: 2;
  id: string;
  profile: {
    firstName: string;
    lastName: string;
    email: string;
  };
  preferences: {
    theme: 'light' | 'dark';
    language: string;
  };
}

現在のバージョンを定数として管理

スキーマバージョンは定数として一元管理することで、メンテナンス性が向上します。

typescript// スキーマバージョン定数の定義
export const SCHEMA_VERSIONS = {
  INITIAL: 1,
  PROFILE_RESTRUCTURE: 2,
  PREFERENCES_ADDED: 2, // v2で追加された機能
  CURRENT: 2, // 現在の最新バージョン
} as const;
typescript// 型ガードの実装
type SchemaVersion =
  (typeof SCHEMA_VERSIONS)[keyof typeof SCHEMA_VERSIONS];

export function isValidSchemaVersion(
  version: unknown
): version is SchemaVersion {
  return (
    typeof version === 'number' &&
    version >= 1 &&
    version <= SCHEMA_VERSIONS.CURRENT
  );
}

以下の図は、バージョン付きスキーマの構造を示しています。

mermaidclassDiagram
  class VersionedSchema {
    +number _version
    +number _createdAt
    +number _updatedAt
  }

  class UserStoreSchemaV1 {
    +number _version = 1
    +string id
    +string name
    +string email
  }

  class UserStoreSchemaV2 {
    +number _version = 2
    +string id
    +Profile profile
    +Preferences preferences
  }

  VersionedSchema <|-- UserStoreSchemaV1
  VersionedSchema <|-- UserStoreSchemaV2

  class Profile {
    +string firstName
    +string lastName
    +string email
  }

  class Preferences {
    +string theme
    +string language
  }

  UserStoreSchemaV2 --> Profile
  UserStoreSchemaV2 --> Preferences

図で理解できる要点:

  • 全てのスキーマが VersionedSchema を継承
  • バージョン番号で各スキーマを識別可能
  • V2 では構造がネスト化されている

マイグレーション関数の設計と実装

バージョン間のデータ変換を行うマイグレーション関数を実装します。各バージョン間の変換を個別の関数として定義することで、テストやデバッグが容易になるんです。

個別マイグレーション関数の実装

各バージョン間の変換は、単一責任の原則に従って個別の関数として実装します。

typescript// V1 から V2 へのマイグレーション関数
function migrateV1ToV2(
  data: UserStoreSchemaV1
): UserStoreSchemaV2 {
  // name を firstName と lastName に分割
  const [firstName, ...lastNameParts] =
    data.name.split(' ');
  const lastName = lastNameParts.join(' ') || firstName;

  return {
    _version: 2,
    _createdAt: Date.now(),
    _updatedAt: Date.now(),
    id: data.id,
    profile: {
      firstName,
      lastName,
      email: data.email,
    },
    preferences: {
      theme: 'light', // デフォルト値
      language: 'ja', // デフォルト値
    },
  };
}

複数のバージョンに対応する場合は、マイグレーション関数のマップを作成します。

typescript// マイグレーション関数のマップ定義
type MigrationFunction = (data: any) => any;

const migrations: Record<number, MigrationFunction> = {
  1: migrateV1ToV2,
  // 将来的な追加例
  // 2: migrateV2ToV3,
  // 3: migrateV3ToV4,
};

チェーンマイグレーションの実装

複数バージョンを跨ぐ場合、段階的にマイグレーションを実行する仕組みが必要です。

typescript// 段階的マイグレーション実行関数
function runMigrations(
  data: any,
  fromVersion: number,
  toVersion: number
): any {
  let currentData = data;

  // 指定バージョンまで順次マイグレーション
  for (let v = fromVersion; v < toVersion; v++) {
    const migrationFn = migrations[v];
    if (!migrationFn) {
      throw new Error(
        `Migration function not found for version ${v}`
      );
    }
    currentData = migrationFn(currentData);
  }

  return currentData;
}
typescript// エラーハンドリング付きマイグレーション実行
function safeMigrate(data: any): any {
  try {
    const currentVersion = data._version || 1;

    // バージョンチェック
    if (!isValidSchemaVersion(currentVersion)) {
      console.warn(
        `Invalid schema version: ${currentVersion}. Using default.`
      );
      return createDefaultStore();
    }

    // 最新版の場合はそのまま返す
    if (currentVersion === SCHEMA_VERSIONS.CURRENT) {
      return data;
    }

    // マイグレーション実行
    return runMigrations(
      data,
      currentVersion,
      SCHEMA_VERSIONS.CURRENT
    );
  } catch (error) {
    console.error('Migration failed:', error);
    // フォールバック:デフォルトストアを返す
    return createDefaultStore();
  }
}

以下の図は、マイグレーションの実行フローを示しています。

mermaidflowchart TD
  start["データ読込"] --> check["バージョン確認"]
  check -->|最新版| return["そのまま返す"]
  check -->|旧版| validate["バージョン検証"]

  validate -->|無効| default["デフォルト生成"]
  validate -->|有効| migrate["マイグレーション実行"]

  migrate --> v1to2["V1→V2変換"]
  v1to2 --> v2to3["V2→V3変換"]
  v2to3 --> done["完了"]

  migrate -->|エラー| error["エラーハンドリング"]
  error --> fallback["フォールバック処理"]

  style default fill:#fff9c4
  style error fill:#ffcdd2
  style done fill:#c8e6c9

図で理解できる要点:

  • バージョンチェックが最初に実行される
  • マイグレーションは段階的に実行
  • エラー時はフォールバック処理が動作

Pinia プラグインとしての統合

マイグレーション機能を Pinia プラグインとして実装することで、全てのストアで自動的にスキーマ管理が適用されます。

プラグインの基本構造

typescript// Pinia マイグレーションプラグインの型定義
import { PiniaPluginContext } from 'pinia';

interface MigrationPluginOptions {
  storage?: Storage; // localStorage or sessionStorage
  key?: string; // ストレージキーのプレフィックス
  enableLogging?: boolean; // デバッグログの有効化
}
typescript// マイグレーションプラグインの実装
export function createMigrationPlugin(
  options: MigrationPluginOptions = {}
) {
  const {
    storage = localStorage,
    key = 'pinia',
    enableLogging = false,
  } = options;

  return (context: PiniaPluginContext) => {
    const { store } = context;
    const storageKey = `${key}_${store.$id}`;

    // 初期化時の処理は次のコードブロックで実装
  };
}

ストアの初期化とマイグレーション実行

プラグインがストアを初期化する際に、自動的にマイグレーションを実行します。

typescript// プラグイン内での初期化処理
return (context: PiniaPluginContext) => {
  const { store } = context;
  const storageKey = `${key}_${store.$id}`;

  // ストレージからデータ読込
  const savedData = storage.getItem(storageKey);

  if (savedData) {
    try {
      const parsed = JSON.parse(savedData);

      // マイグレーション実行
      const migrated = safeMigrate(parsed);

      if (enableLogging) {
        console.log(
          `[Migration] ${store.$id}: v${parsed._version} → v${migrated._version}`
        );
      }

      // ストアにマイグレーション後のデータを設定
      store.$patch(migrated);
    } catch (error) {
      console.error(
        `[Migration] Failed to migrate ${store.$id}:`,
        error
      );
    }
  }
};

自動永続化の実装

ストアの変更を自動的に永続化し、常に最新のバージョン情報を保存します。

typescript// ストア変更の監視と自動保存
return (context: PiniaPluginContext) => {
  const { store } = context;

  // ... 初期化処理 ...

  // ストア変更時の自動保存
  store.$subscribe(
    (mutation, state) => {
      // バージョン情報を付与
      const versionedState = {
        ...state,
        _version: SCHEMA_VERSIONS.CURRENT,
        _updatedAt: Date.now(),
      };

      // ストレージに保存
      try {
        storage.setItem(
          storageKey,
          JSON.stringify(versionedState)
        );

        if (enableLogging) {
          console.log(`[Persist] ${store.$id} saved`);
        }
      } catch (error) {
        console.error(
          `[Persist] Failed to save ${store.$id}:`,
          error
        );
      }
    },
    { detached: true }
  );
};

互換性ポリシーの策定

スキーマ変更を安全に管理するためには、明確な互換性ポリシーが必要です。

セマンティックバージョニングの適用

スキーマバージョンにセマンティックバージョニングの概念を適用できます。

#変更タイプバージョン変更互換性
1パッチ(軽微な修正)x.x.1 → x.x.2バグ修正、デフォルト値変更完全互換
2マイナー(機能追加)x.1.x → x.2.x新プロパティ追加後方互換
3メジャー(破壊的変更)1.x.x → 2.x.xプロパティ削除、型変更非互換
typescript// セマンティックバージョンの型定義
interface SemanticVersion {
  major: number;
  minor: number;
  patch: number;
}
typescript// バージョン比較関数
function parseVersion(version: string): SemanticVersion {
  const [major, minor, patch] = version
    .split('.')
    .map(Number);
  return { major, minor, patch };
}

function isBackwardCompatible(
  from: SemanticVersion,
  to: SemanticVersion
): boolean {
  // メジャーバージョンが異なる場合は非互換
  if (from.major !== to.major) return false;

  // マイナー・パッチバージョンは後方互換
  return to.minor >= from.minor;
}

非推奨フィールドの段階的削除

破壊的変更を避けるため、フィールドの削除は段階的に行います。

typescript// 非推奨フィールドを含むスキーマ(V2)
interface UserStoreSchemaV2 {
  _version: 2;
  id: string;
  name: string; // 非推奨(V3 で削除予定)
  profile: {
    firstName: string;
    lastName: string;
  };
  /** @deprecated Use profile.firstName and profile.lastName instead */
  _deprecated_name?: string; // 互換性のため残す
}

以下の図は、段階的な非推奨化プロセスを示しています。

mermaidstateDiagram-v2
  [*] --> Active: V1 で導入
  Active --> Deprecated: V2 で非推奨マーク
  Deprecated --> Warning: V3 で警告表示
  Warning --> Removed: V4 で完全削除
  Removed --> [*]

  note right of Deprecated
    既存コードは動作するが
    警告が表示される
  end note

  note right of Warning
    マイグレーション推奨
    エラーログ出力
  end note

図で理解できる要点:

  • フィールド削除は 4 段階で実施
  • ユーザーに十分な移行期間を提供
  • 段階的に警告レベルを上げる

具体例

実践的なユーザーストアの実装

ここからは、実際のアプリケーションで使用できるユーザーストアの完全な実装例を見ていきましょう。

スキーマ定義の完全版

typescript// schemas/user-store.schema.ts

// 共通の型定義
export interface VersionedSchema {
  _version: number;
  _createdAt?: number;
  _updatedAt?: number;
}

// スキーマバージョン定数
export const USER_STORE_VERSION = {
  V1_INITIAL: 1,
  V2_PROFILE_SPLIT: 2,
  V3_PREFERENCES: 3,
  CURRENT: 3,
} as const;
typescript// V1: 初期バージョン(シンプルな構造)
export interface UserStoreSchemaV1 extends VersionedSchema {
  _version: 1;
  id: string;
  name: string;
  email: string;
  role: 'user' | 'admin';
}
typescript// V2: プロフィール情報の分離
export interface UserStoreSchemaV2 extends VersionedSchema {
  _version: 2;
  id: string;
  profile: {
    firstName: string;
    lastName: string;
    email: string;
    avatar?: string;
  };
  role: 'user' | 'admin';
}
typescript// V3: ユーザー設定の追加(最新版)
export interface UserStoreSchemaV3 extends VersionedSchema {
  _version: 3;
  id: string;
  profile: {
    firstName: string;
    lastName: string;
    email: string;
    avatar?: string;
  };
  role: 'user' | 'admin' | 'moderator'; // 新しいロール追加
  preferences: {
    theme: 'light' | 'dark' | 'auto';
    language: string;
    notifications: {
      email: boolean;
      push: boolean;
    };
  };
  metadata: {
    lastLoginAt: number;
    loginCount: number;
  };
}

マイグレーション関数の完全実装

各バージョン間のマイグレーションロジックを実装します。

typescript// migrations/user-store.migrations.ts

// V1 → V2 マイグレーション
export function migrateUserStoreV1ToV2(
  data: UserStoreSchemaV1
): UserStoreSchemaV2 {
  // name を firstName と lastName に分割
  const nameParts = data.name.trim().split(' ');
  const firstName = nameParts[0] || 'User';
  const lastName = nameParts.slice(1).join(' ') || '';

  return {
    _version: 2,
    _createdAt: data._createdAt || Date.now(),
    _updatedAt: Date.now(),
    id: data.id,
    profile: {
      firstName,
      lastName,
      email: data.email,
      avatar: undefined, // 新しいフィールドはundefined
    },
    role: data.role,
  };
}
typescript// V2 → V3 マイグレーション
export function migrateUserStoreV2ToV3(
  data: UserStoreSchemaV2
): UserStoreSchemaV3 {
  return {
    _version: 3,
    _createdAt: data._createdAt || Date.now(),
    _updatedAt: Date.now(),
    id: data.id,
    profile: { ...data.profile },
    role: data.role,
    // 新しいフィールドにデフォルト値を設定
    preferences: {
      theme: 'auto',
      language: navigator.language || 'ja',
      notifications: {
        email: true,
        push: false,
      },
    },
    metadata: {
      lastLoginAt: Date.now(),
      loginCount: 0,
    },
  };
}
typescript// マイグレーションマップの定義
const userStoreMigrations = {
  1: migrateUserStoreV1ToV2,
  2: migrateUserStoreV2ToV3,
};

// 統合マイグレーション実行関数
export function migrateUserStore(
  data: any
): UserStoreSchemaV3 {
  const version = data._version || 1;
  let current = data;

  for (
    let v = version;
    v < USER_STORE_VERSION.CURRENT;
    v++
  ) {
    const migrateFn = userStoreMigrations[v];
    if (migrateFn) {
      current = migrateFn(current);
    }
  }

  return current as UserStoreSchemaV3;
}

以下の図は、マイグレーションの実行フローを詳細に示しています。

mermaidsequenceDiagram
  participant App as アプリ起動
  participant Plugin as Migration Plugin
  participant Storage as LocalStorage
  participant Migrate as Migration Engine
  participant Store as Pinia Store

  App->>Plugin: ストア初期化
  Plugin->>Storage: データ読込
  Storage-->>Plugin: JSON データ

  Plugin->>Plugin: バージョン確認
  alt バージョン古い
    Plugin->>Migrate: マイグレーション実行
    Migrate->>Migrate: V1→V2変換
    Migrate->>Migrate: V2→V3変換
    Migrate-->>Plugin: 変換済データ
  else 最新版
    Plugin->>Plugin: そのまま使用
  end

  Plugin->>Store: データ適用
  Store-->>App: 初期化完了

図で理解できる要点:

  • プラグインが自動的にマイグレーションを実行
  • バージョンに応じて段階的に変換
  • ストアには常に最新版のデータが格納される

Pinia ストアの定義

マイグレーション機能を組み込んだストアを定義します。

typescript// stores/user.store.ts
import { defineStore } from 'pinia';
import type { UserStoreSchemaV3 } from '@/schemas/user-store.schema';

export const useUserStore = defineStore('user', {
  state: (): UserStoreSchemaV3 => ({
    _version: 3,
    _createdAt: Date.now(),
    _updatedAt: Date.now(),
    id: '',
    profile: {
      firstName: '',
      lastName: '',
      email: '',
      avatar: undefined,
    },
    role: 'user',
    preferences: {
      theme: 'auto',
      language: 'ja',
      notifications: {
        email: true,
        push: false,
      },
    },
    metadata: {
      lastLoginAt: 0,
      loginCount: 0,
    },
  }),

  getters: {
    // フルネームを取得
    fullName: (state) => {
      return `${state.profile.firstName} ${state.profile.lastName}`.trim();
    },

    // テーマ設定を取得
    effectiveTheme: (state) => {
      if (state.preferences.theme === 'auto') {
        return window.matchMedia(
          '(prefers-color-scheme: dark)'
        ).matches
          ? 'dark'
          : 'light';
      }
      return state.preferences.theme;
    },
  },

  actions: {
    // ユーザー情報更新
    updateProfile(
      profile: Partial<UserStoreSchemaV3['profile']>
    ) {
      this.profile = { ...this.profile, ...profile };
      this._updatedAt = Date.now();
    },

    // 設定更新
    updatePreferences(
      preferences: Partial<UserStoreSchemaV3['preferences']>
    ) {
      this.preferences = {
        ...this.preferences,
        ...preferences,
      };
      this._updatedAt = Date.now();
    },
  },
});

プラグインの設定と登録

最後に、アプリケーションのエントリーポイントでプラグインを登録します。

typescript// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const app = createApp(App);
const pinia = createPinia();
typescript// マイグレーションプラグインのインポートと設定
import { createMigrationPlugin } from '@/plugins/migration.plugin';
import { migrateUserStore } from '@/migrations/user-store.migrations';

const migrationPlugin = createMigrationPlugin({
  migrations: {
    user: migrateUserStore, // ストア名とマイグレーション関数を紐付け
  },
  storage: localStorage,
  enableLogging: import.meta.env.DEV, // 開発環境でのみログ出力
});

pinia.use(migrationPlugin);
app.use(pinia);
app.mount('#app');

バージョン検証とエラーハンドリング

本番環境で安全に運用するために、堅牢なエラーハンドリングを実装しましょう。

typescript// utils/schema-validator.ts

// スキーマバリデーション結果の型
interface ValidationResult {
  isValid: boolean;
  errors: string[];
  warnings: string[];
}
typescript// V3スキーマのバリデーター
export function validateUserStoreV3(
  data: any
): ValidationResult {
  const errors: string[] = [];
  const warnings: string[] = [];

  // バージョンチェック
  if (data._version !== 3) {
    errors.push(
      `Invalid version: expected 3, got ${data._version}`
    );
  }

  // 必須フィールドチェック
  if (!data.id) errors.push('Missing required field: id');
  if (!data.profile)
    errors.push('Missing required field: profile');
  if (!data.role)
    errors.push('Missing required field: role');

  // プロフィールの詳細チェック
  if (data.profile) {
    if (!data.profile.firstName) {
      errors.push(
        'Missing required field: profile.firstName'
      );
    }
    if (!data.profile.email) {
      errors.push('Missing required field: profile.email');
    }
  }

  // 非推奨フィールドの警告
  if ('name' in data) {
    warnings.push(
      'Deprecated field "name" found. Use profile.firstName and profile.lastName instead.'
    );
  }

  return {
    isValid: errors.length === 0,
    errors,
    warnings,
  };
}
typescript// バリデーション付きマイグレーション
export function safelyMigrateUserStore(
  data: any
): UserStoreSchemaV3 {
  try {
    // マイグレーション実行
    const migrated = migrateUserStore(data);

    // バリデーション
    const validation = validateUserStoreV3(migrated);

    // 警告の出力
    if (validation.warnings.length > 0) {
      console.warn(
        '[Migration] Warnings:',
        validation.warnings
      );
    }

    // エラーチェック
    if (!validation.isValid) {
      console.error(
        '[Migration] Validation errors:',
        validation.errors
      );
      throw new Error('Schema validation failed');
    }

    return migrated;
  } catch (error) {
    console.error('[Migration] Migration failed:', error);
    // デフォルト値を返す
    return createDefaultUserStore();
  }
}

ロールバック機能の実装

マイグレーションに失敗した場合や、問題が発生した場合に備えて、ロールバック機能を実装します。

typescript// utils/rollback.ts

// バックアップデータの型
interface BackupData {
  timestamp: number;
  version: number;
  data: any;
}
typescript// バックアップ作成関数
export function createBackup(
  storeId: string,
  data: any
): void {
  const backup: BackupData = {
    timestamp: Date.now(),
    version: data._version || 1,
    data: JSON.parse(JSON.stringify(data)), // ディープコピー
  };

  const backupKey = `${storeId}_backup_${backup.timestamp}`;
  localStorage.setItem(backupKey, JSON.stringify(backup));

  // 古いバックアップを削除(最新5件のみ保持)
  cleanOldBackups(storeId, 5);
}
typescript// バックアップのリストア
export function restoreBackup(
  storeId: string,
  timestamp?: number
): any | null {
  if (timestamp) {
    // 特定のタイムスタンプのバックアップをリストア
    const backupKey = `${storeId}_backup_${timestamp}`;
    const backup = localStorage.getItem(backupKey);
    return backup ? JSON.parse(backup).data : null;
  }

  // 最新のバックアップをリストア
  const backups = getBackupList(storeId);
  if (backups.length === 0) return null;

  const latestBackup = backups[backups.length - 1];
  return JSON.parse(latestBackup).data;
}

function getBackupList(storeId: string): string[] {
  const keys = Object.keys(localStorage);
  return keys
    .filter((key) => key.startsWith(`${storeId}_backup_`))
    .sort();
}

function cleanOldBackups(
  storeId: string,
  keepCount: number
): void {
  const backups = getBackupList(storeId);
  const toDelete = backups.slice(0, -keepCount);
  toDelete.forEach((key) => localStorage.removeItem(key));
}

以下の図は、バックアップとロールバックの仕組みを示しています。

mermaidflowchart TB
  subgraph backup["バックアップフロー"]
    save["データ保存"] --> create["バックアップ作成"]
    create --> store1["LocalStorage保存"]
    store1 --> clean["古いバックアップ削除"]
  end

  subgraph rollback["ロールバックフロー"]
    error["エラー検出"] --> find["最新バックアップ検索"]
    find --> restore["データ復元"]
    restore --> validate["整合性確認"]
    validate --> apply["ストア適用"]
  end

  save -.->|エラー発生| error

  style error fill:#ffcdd2
  style restore fill:#fff9c4
  style apply fill:#c8e6c9

図で理解できる要点:

  • 保存時に自動的にバックアップ作成
  • エラー時は最新バックアップから復元
  • 古いバックアップは自動削除される

まとめ

Pinia ストアのスキーマ変更管理は、アプリケーションの成長に伴って避けられない課題です。本記事では、バージョン付与からマイグレーション、互換性ポリシーまで、実践的な実装方法をご紹介しました。

重要なポイントの振り返り

スキーマバージョン管理では、全てのスキーマに _version フィールドを付与し、データの形式を明確に識別できるようにすることが基本となります。これにより、どのバージョンのデータなのかを瞬時に判断でき、適切なマイグレーション処理を実行できるようになるんです。

マイグレーション戦略については、各バージョン間の変換を個別の関数として実装し、段階的に実行する仕組みが効果的でした。一度に大量の変更を行うのではなく、小さなステップに分けることで、デバッグやテストが容易になり、問題が発生した際の原因特定もスムーズになります。

互換性ポリシーでは、セマンティックバージョニングの概念を適用し、変更の影響度に応じてバージョン番号を管理します。破壊的な変更を避け、段階的な非推奨化プロセスを経ることで、ユーザーに十分な移行期間を提供できるでしょう。

実装時の推奨事項

実際のプロジェクトで導入する際は、以下の点を意識してください:

#項目推奨内容
1バックアップマイグレーション前に必ずバックアップを作成
2ログ出力開発環境でマイグレーションログを有効化
3バリデーションマイグレーション後のデータ検証を必ず実施
4テスト各マイグレーション関数の単体テストを作成
5ドキュメントスキーマ変更履歴をチームで共有

エラーハンドリングも非常に重要です。マイグレーションに失敗した場合は、デフォルト値を返すフォールバック処理や、バックアップからのリストア機能を実装しておくことで、ユーザーデータの喪失を防げます。

段階的な導入アプローチ

既存のプロジェクトに導入する場合は、以下のステップで進めることをお勧めします。まず、現在のスキーマを V1 として定義し、バージョン情報を付与します。次に、マイグレーションプラグインを実装し、テスト環境で動作を確認してください。問題がなければ、段階的に本番環境へデプロイし、ユーザーデータのマイグレーション状況を監視しましょう。

この手法を活用することで、スキーマ変更に伴うリスクを最小限に抑えながら、アプリケーションを継続的に進化させることができます。型安全性を保ちながら、ユーザーデータの整合性も維持できるため、安心して新機能の開発に集中できるでしょう。

スキーマ管理は一見複雑に思えるかもしれませんが、一度仕組みを整えてしまえば、その後の開発効率が大きく向上するはずです。ぜひ、本記事の内容を参考に、あなたのプロジェクトに適したスキーマ管理戦略を構築してみてください。

関連リンク