diff --git a/src/store/__tests__/persistenceLayerPrune.test.ts b/src/store/__tests__/persistenceLayerPrune.test.ts new file mode 100644 index 00000000..70d4bf67 --- /dev/null +++ b/src/store/__tests__/persistenceLayerPrune.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + persistedStateVersion, + pruneUnknownKeys, +} from '../persistenceLayer'; +import { migratePersistedStoreState } from '../stateManager'; + +describe('pruneUnknownKeys', () => { + it('keeps only the allowed keys', () => { + const value = { + user: { id: 'u1' }, + app: { offlineMode: true }, + rogueSlice: { anything: 1 }, + anotherStaleSlice: [1, 2, 3], + }; + const pruned = pruneUnknownKeys(value, ['user', 'app']); + expect(Object.keys(pruned)).toEqual(['user', 'app']); + expect(pruned.app).toEqual({ offlineMode: true }); + }); + + it('returns an empty object when nothing is allowed', () => { + expect(pruneUnknownKeys({ a: 1, b: 2 }, [])).toEqual({}); + }); +}); + +describe('persistedStateVersion', () => { + it('extracts the version from a versioned payload', () => { + const raw = JSON.stringify({ state: { user: {} }, version: 2 }); + expect(persistedStateVersion(raw)).toBe(2); + }); + + it('returns undefined for an unversioned payload', () => { + expect(persistedStateVersion(JSON.stringify({ state: {}, version: null }))).toBeUndefined(); + }); + + it('returns undefined for invalid JSON', () => { + expect(persistedStateVersion('not-json')).toBeUndefined(); + }); + + it('returns undefined for an empty value', () => { + expect(persistedStateVersion(null)).toBeUndefined(); + }); +}); + +describe('migratePersistedStoreState', () => { + it('returns the payload untouched when versions match', () => { + const state = { user: { id: 'u1' }, app: { offlineMode: true } }; + expect(migratePersistedStoreState(state, 1)).toEqual(state); + }); + + it('drops unknown slices on a version mismatch', () => { + const stale = { + user: { id: 'u1' }, + app: { offlineMode: true }, + discontinuedSlice: { data: 123 }, + }; + const migrated = migratePersistedStoreState(stale, 0) as Record; + expect(Object.keys(migrated)).toEqual(['user', 'app']); + expect(migrated.user).toEqual({ id: 'u1' }); + }); + + it('returns null when nothing was persisted', () => { + expect(migratePersistedStoreState(null, 0)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/store/stateManager.ts b/src/store/stateManager.ts index 42fae13e..de06fa41 100644 --- a/src/store/stateManager.ts +++ b/src/store/stateManager.ts @@ -1,10 +1,39 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; -import { persistenceLayer } from './persistenceLayer'; +import { persistenceLayer, pruneUnknownKeys } from './persistenceLayer'; import { deepMerge } from '../utils/stateUtils'; import { stateLogger } from './devTools'; import { UserRole } from '../types/api'; +/** + * Version of the persisted store schema. Bump when slices are added/removed or + * their shapes change so stale persisted state is pruned (unknown slices are + * dropped) instead of hydrating invalid state. + */ +export const PERSISTED_SCHEMA_VERSION = 1 as const; + +/** Top-level slices the current store knows about and may hydrate. */ +const PERSISTED_ALLOWED_KEYS = ['user', 'app'] as const; + +/** + * Zustand persist migration: prunes unknown/stale slices when a previously + * persisted payload was written under an older schema version. + */ +export function migratePersistedStoreState( + persistedState: unknown, + version: number, +): unknown { + if (version === PERSISTED_SCHEMA_VERSION || persistedState == null) { + return persistedState; + } + return { + ...pruneUnknownKeys( + persistedState as Record, + PERSISTED_ALLOWED_KEYS, + ), + }; +} + interface UserState { id: string | null; name: string | null; @@ -157,6 +186,8 @@ export const useStore = create()( }), { name: 'teachlink-storage', + version: PERSISTED_SCHEMA_VERSION, + migrate: migratePersistedStoreState, storage: createJSONStorage(() => persistenceLayer), partialize: (state: StoreState) => ({ user: state.user,