Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/store/__tests__/persistenceLayerPrune.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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();
});
});
33 changes: 32 additions & 1 deletion src/store/stateManager.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
PERSISTED_ALLOWED_KEYS,
),
};
}

interface UserState {
id: string | null;
name: string | null;
Expand Down Expand Up @@ -157,6 +186,8 @@ export const useStore = create<StoreState>()(
}),
{
name: 'teachlink-storage',
version: PERSISTED_SCHEMA_VERSION,
migrate: migratePersistedStoreState,
storage: createJSONStorage(() => persistenceLayer),
partialize: (state: StoreState) => ({
user: state.user,
Expand Down
Loading