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 として定義し、バージョン情報を付与します。次に、マイグレーションプラグインを実装し、テスト環境で動作を確認してください。問題がなければ、段階的に本番環境へデプロイし、ユーザーデータのマイグレーション状況を監視しましょう。
この手法を活用することで、スキーマ変更に伴うリスクを最小限に抑えながら、アプリケーションを継続的に進化させることができます。型安全性を保ちながら、ユーザーデータの整合性も維持できるため、安心して新機能の開発に集中できるでしょう。
スキーマ管理は一見複雑に思えるかもしれませんが、一度仕組みを整えてしまえば、その後の開発効率が大きく向上するはずです。ぜひ、本記事の内容を参考に、あなたのプロジェクトに適したスキーマ管理戦略を構築してみてください。
関連リンク
articlePinia ストアスキーマの変更管理:バージョン付与・マイグレーション・互換ポリシー
articlePinia トランザクション更新設計:一括 set・部分適用・ロールバックの整合モデル
articlePinia アクション設計 50 レシピ:リトライ・デバウンス・キャンセル・同時実行制御
articlePinia × VueUse × Vite 雛形:型安全ストアとユーティリティを最短で組む
articlePinia と VueUse の useStorage/useFetch 比較:軽量レシピで代替できる境界
articlePinia ストア間の循環参照を断つ:依存分解とイベント駆動の現場テク
articlePinia ストアスキーマの変更管理:バージョン付与・マイグレーション・互換ポリシー
articleshadcn/ui コンポーネント置換マップ:用途別に最短でたどり着く選定表
articleOllama のコスト最適化:モデルサイズ・VRAM 使用量・バッチ化の実践
articleRemix Loader/Action チートシート:Request/Response API 逆引き大全
articleObsidian タスク運用の最適解:Tasks + Periodic Notes で計画と実行を接続
articlePreact Signals チートシート:signal/computed/effect 実用スニペット 30
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来