feat(web): add Zustand store migration strategy

- Document migration approach in ZUSTAND_MIGRATION_STRATEGY.md
- Add persistWithMigration utility for future stores
- Add version and migrate to authStore, library, ui, cartStore, playerStore
This commit is contained in:
senke 2026-02-14 22:43:06 +01:00
parent 791eedccae
commit 75c027c5bd
7 changed files with 137 additions and 0 deletions

View file

@ -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.

View file

@ -360,12 +360,24 @@ export const useAuthStore = create<AuthStore>()(
),
{
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<StoreApi<AuthStore>>;

View file

@ -240,6 +240,7 @@ export const usePlayerStore = create<PlayerStore>()(
}),
{
name: 'player-storage',
version: 1,
partialize: (state) => ({
volume: state.volume,
muted: state.muted,
@ -249,6 +250,18 @@ export const usePlayerStore = create<PlayerStore>()(
currentIndex: state.currentIndex,
currentTrack: state.currentTrack,
}),
migrate: (persistedState: unknown) => {
const s = persistedState as Partial<PlayerStore> | 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,
};
},
},
),
);

View file

@ -94,6 +94,11 @@ export const useCartStore = create<CartStore>()(
}),
{
name: 'veza-cart-storage',
version: 1,
migrate: (persistedState: unknown) => {
const s = persistedState as { items?: CartItem[] } | null;
return { items: s?.items ?? [] };
},
},
),
);

View file

@ -43,11 +43,16 @@ export const useLibraryStore = create<LibraryStore>()(
}),
{
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<string, string> } | null;
return { filters: s?.filters ?? {} };
},
},
),
{

View file

@ -106,11 +106,20 @@ export const useUIStore = create<UIStore>()(
),
{
name: 'ui-storage',
version: 1,
partialize: (state) => ({
theme: state.theme,
language: state.language,
sidebarOpen: state.sidebarOpen,
}),
migrate: (persistedState: unknown) => {
const s = persistedState as Partial<UIStore> | null;
return {
theme: s?.theme ?? 'dark',
language: s?.language ?? 'en',
sidebarOpen: s?.sidebarOpen ?? true,
};
},
},
),
{

View file

@ -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<T>(
name: string,
version: number,
migrate: (state: unknown, version: number) => T,
partialize?: (state: T) => Partial<T>,
): {
name: string;
version: number;
migrate: (state: unknown, version: number) => T;
partialize?: (state: T) => Partial<T>;
} {
return {
name,
version,
migrate,
...(partialize && { partialize }),
};
}