diff --git a/apps/web/docs/ZUSTAND_MIGRATION_STRATEGY.md b/apps/web/docs/ZUSTAND_MIGRATION_STRATEGY.md new file mode 100644 index 000000000..1faf357be --- /dev/null +++ b/apps/web/docs/ZUSTAND_MIGRATION_STRATEGY.md @@ -0,0 +1,63 @@ +# Zustand Store Migration Strategy + +When the persisted store schema changes, we need a migration strategy to avoid corruption or runtime errors. + +## Approach + +1. **Version in storage key**: Each store uses a versioned storage key (e.g. `auth-storage-v1`). +2. **`migrate` callback**: The Zustand `persist` middleware accepts a `migrate` function that transforms old state to new schema. +3. **Fallback reset**: If migration fails, reset to default state rather than crash. + +## migrate Function Signature + +```ts +(state: unknown, version: number) => NewState +``` + +- `state`: The raw persisted value from localStorage (may be from an older version). +- `version`: The version number from the persisted state (0 if never set). + +## Example + +```ts +persist( + (set) => ({ ... }), + { + name: 'auth-storage', + version: 1, + migrate: (persistedState, version) => { + if (version === 0) { + // Old schema: { user, token, ... } + // New schema: { isAuthenticated, isLoading, error } + return { + isAuthenticated: !!persistedState?.user, + isLoading: false, + error: null, + }; + } + return persistedState as AuthState; + }, + } +) +``` + +## Stores Using Persist + +- `authStore` — auth-storage +- `library` — library-storage +- `ui` — ui-storage +- `cartStore` — cart-storage +- `playerStore` — player-storage + +## When to Bump Version + +Bump `version` when: +- Adding/removing persisted fields +- Changing the shape of persisted data +- Renaming keys + +## Testing Migration + +1. Manually set old state in localStorage. +2. Reload the app. +3. Verify the store hydrates correctly. diff --git a/apps/web/src/features/auth/store/authStore.ts b/apps/web/src/features/auth/store/authStore.ts index 7395dbbbc..9ba9a1dc7 100644 --- a/apps/web/src/features/auth/store/authStore.ts +++ b/apps/web/src/features/auth/store/authStore.ts @@ -360,12 +360,24 @@ export const useAuthStore = create()( ), { name: 'auth-storage', + version: 1, partialize: (state) => ({ // Action 4.1.1.5: user field removed - user data managed by React Query isAuthenticated: state.isAuthenticated, // Note: Les tokens sont stockés dans TokenStorage (localStorage direct) // User data is now managed by React Query (useUser hook), not persisted here }), + migrate: (persistedState: unknown, version: number) => { + if (version === 0 || !persistedState) { + const s = persistedState as { isAuthenticated?: boolean } | null; + return { + isAuthenticated: !!s?.isAuthenticated, + isLoading: false, + error: null, + }; + } + return persistedState as AuthState; + }, }, ), ) as UseBoundStore>; diff --git a/apps/web/src/features/player/store/playerStore.ts b/apps/web/src/features/player/store/playerStore.ts index 865aae88a..f1c7884ff 100644 --- a/apps/web/src/features/player/store/playerStore.ts +++ b/apps/web/src/features/player/store/playerStore.ts @@ -240,6 +240,7 @@ export const usePlayerStore = create()( }), { name: 'player-storage', + version: 1, partialize: (state) => ({ volume: state.volume, muted: state.muted, @@ -249,6 +250,18 @@ export const usePlayerStore = create()( currentIndex: state.currentIndex, currentTrack: state.currentTrack, }), + migrate: (persistedState: unknown) => { + const s = persistedState as Partial | null; + return { + volume: s?.volume ?? 100, + muted: s?.muted ?? false, + repeat: (s?.repeat as 'off' | 'one' | 'all') ?? 'off', + shuffle: s?.shuffle ?? false, + queue: s?.queue ?? [], + currentIndex: s?.currentIndex ?? -1, + currentTrack: s?.currentTrack ?? null, + }; + }, }, ), ); diff --git a/apps/web/src/stores/cartStore.ts b/apps/web/src/stores/cartStore.ts index 5ea4c0341..74eb891bc 100644 --- a/apps/web/src/stores/cartStore.ts +++ b/apps/web/src/stores/cartStore.ts @@ -94,6 +94,11 @@ export const useCartStore = create()( }), { name: 'veza-cart-storage', + version: 1, + migrate: (persistedState: unknown) => { + const s = persistedState as { items?: CartItem[] } | null; + return { items: s?.items ?? [] }; + }, }, ), ); diff --git a/apps/web/src/stores/library.ts b/apps/web/src/stores/library.ts index 1d3bfd83f..ebe4173e2 100644 --- a/apps/web/src/stores/library.ts +++ b/apps/web/src/stores/library.ts @@ -43,11 +43,16 @@ export const useLibraryStore = create()( }), { name: 'library-storage', + version: 1, partialize: (state) => ({ // FE-STATE-001: Persist filters for offline support // NOTE: favorites removed - React Query handles persistence filters: state.filters, }), + migrate: (persistedState: unknown) => { + const s = persistedState as { filters?: Record } | null; + return { filters: s?.filters ?? {} }; + }, }, ), { diff --git a/apps/web/src/stores/ui.ts b/apps/web/src/stores/ui.ts index ee5f3d8be..d34950fa0 100644 --- a/apps/web/src/stores/ui.ts +++ b/apps/web/src/stores/ui.ts @@ -106,11 +106,20 @@ export const useUIStore = create()( ), { name: 'ui-storage', + version: 1, partialize: (state) => ({ theme: state.theme, language: state.language, sidebarOpen: state.sidebarOpen, }), + migrate: (persistedState: unknown) => { + const s = persistedState as Partial | null; + return { + theme: s?.theme ?? 'dark', + language: s?.language ?? 'en', + sidebarOpen: s?.sidebarOpen ?? true, + }; + }, }, ), { diff --git a/apps/web/src/utils/persistWithMigration.ts b/apps/web/src/utils/persistWithMigration.ts new file mode 100644 index 000000000..9765da23d --- /dev/null +++ b/apps/web/src/utils/persistWithMigration.ts @@ -0,0 +1,30 @@ +/** + * Helper to create persist config with version and migrate. + * Use when adding new persisted stores to ensure future schema changes are handled. + * + * @example + * ```ts + * persist( + * (set) => ({ ... }), + * persistWithMigration('my-storage', 1, (state, version) => state ?? defaultState) + * ) + * ``` + */ +export function persistWithMigration( + name: string, + version: number, + migrate: (state: unknown, version: number) => T, + partialize?: (state: T) => Partial, +): { + name: string; + version: number; + migrate: (state: unknown, version: number) => T; + partialize?: (state: T) => Partial; +} { + return { + name, + version, + migrate, + ...(partialize && { partialize }), + }; +}