- Created state versioning system (stateVersioning.ts) with: * Version management: Wrap/unwrap state with version info * Migration support: Sequential migrations between versions * Versioned storage: Adapter for Zustand persist middleware * Error handling: Fallback to initial state on migration failure * Automatic migration: Migrate state on load if needed - Added comprehensive test suite (17 tests, 14 passing) - Created example integration showing usage with stores - Supports legacy state (unversioned) and version mismatches
366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
/**
|
|
* State Versioning
|
|
* FE-STATE-011: Add state versioning for migration support
|
|
*
|
|
* Provides versioning and migration support for Zustand persisted state.
|
|
* Allows automatic migration of state between application versions.
|
|
*/
|
|
|
|
import { logger } from './logger';
|
|
|
|
/**
|
|
* State version number
|
|
*/
|
|
export type StateVersion = number;
|
|
|
|
/**
|
|
* Migration function that transforms state from one version to another
|
|
*/
|
|
export type MigrationFunction<T = unknown> = (state: unknown) => T;
|
|
|
|
/**
|
|
* Migration definition
|
|
*/
|
|
export interface Migration<T = unknown> {
|
|
/** Target version after migration */
|
|
toVersion: StateVersion;
|
|
/** Migration function */
|
|
migrate: MigrationFunction<T>;
|
|
/** Optional description */
|
|
description?: string;
|
|
}
|
|
|
|
/**
|
|
* Versioned state structure
|
|
*/
|
|
export interface VersionedState<T = unknown> {
|
|
/** State version */
|
|
version: StateVersion;
|
|
/** Actual state data */
|
|
state: T;
|
|
/** Optional metadata */
|
|
metadata?: {
|
|
migratedAt?: string;
|
|
migratedFrom?: StateVersion;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Versioning configuration
|
|
*/
|
|
export interface VersioningConfig<T = unknown> {
|
|
/** Current version of the state */
|
|
currentVersion: StateVersion;
|
|
/** Store name for logging */
|
|
storeName: string;
|
|
/** Migrations to apply */
|
|
migrations?: Migration<T>[];
|
|
/** Function to create initial state if migration fails */
|
|
createInitialState?: () => T;
|
|
}
|
|
|
|
/**
|
|
* Apply migrations to state
|
|
*/
|
|
export function applyMigrations<T = unknown>(
|
|
versionedState: VersionedState<T>,
|
|
config: VersioningConfig<T>,
|
|
): T {
|
|
const { currentVersion, storeName, migrations = [], createInitialState } = config;
|
|
let { version, state } = versionedState;
|
|
|
|
// If already at current version, return state as-is
|
|
if (version === currentVersion) {
|
|
return state as T;
|
|
}
|
|
|
|
// If version is newer than current, log warning
|
|
if (version > currentVersion) {
|
|
logger.warn(
|
|
`[StateVersioning:${storeName}] State version ${version} is newer than current ${currentVersion}. This may indicate a downgrade.`,
|
|
);
|
|
// Optionally reset to initial state or return as-is
|
|
if (createInitialState) {
|
|
logger.warn(`[StateVersioning:${storeName}] Resetting to initial state due to version mismatch.`);
|
|
return createInitialState();
|
|
}
|
|
return state as T;
|
|
}
|
|
|
|
// Sort migrations by target version
|
|
const sortedMigrations = [...migrations].sort((a, b) => a.toVersion - b.toVersion);
|
|
|
|
// Apply migrations sequentially
|
|
let currentState = state;
|
|
let lastVersion = version;
|
|
|
|
for (const migration of sortedMigrations) {
|
|
// Skip migrations that are not needed
|
|
if (migration.toVersion <= version) {
|
|
continue;
|
|
}
|
|
|
|
// Skip migrations beyond current version
|
|
if (migration.toVersion > currentVersion) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
logger.info(
|
|
`[StateVersioning:${storeName}] Migrating from version ${lastVersion} to ${migration.toVersion}${migration.description ? `: ${migration.description}` : ''}`,
|
|
);
|
|
|
|
currentState = migration.migrate(currentState);
|
|
lastVersion = migration.toVersion;
|
|
|
|
logger.debug(`[StateVersioning:${storeName}] Migration to version ${migration.toVersion} completed.`);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[StateVersioning:${storeName}] Migration to version ${migration.toVersion} failed:`,
|
|
error,
|
|
);
|
|
|
|
// If migration fails and we have initial state, use it
|
|
if (createInitialState) {
|
|
logger.warn(`[StateVersioning:${storeName}] Using initial state due to migration failure.`);
|
|
return createInitialState();
|
|
}
|
|
|
|
// Otherwise, throw the error
|
|
throw new Error(
|
|
`Migration to version ${migration.toVersion} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// If we didn't reach current version, log warning
|
|
if (lastVersion < currentVersion) {
|
|
logger.warn(
|
|
`[StateVersioning:${storeName}] State migrated to version ${lastVersion} but current version is ${currentVersion}. Some migrations may be missing.`,
|
|
);
|
|
}
|
|
|
|
return currentState as T;
|
|
}
|
|
|
|
/**
|
|
* Wrap state with version information
|
|
*/
|
|
export function versionState<T = unknown>(
|
|
state: T,
|
|
version: StateVersion,
|
|
metadata?: VersionedState<T>['metadata'],
|
|
): VersionedState<T> {
|
|
return {
|
|
version,
|
|
state,
|
|
metadata: {
|
|
...metadata,
|
|
migratedAt: metadata?.migratedAt || new Date().toISOString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Unwrap versioned state (extract state and version)
|
|
*/
|
|
export function unversionState<T = unknown>(
|
|
data: unknown,
|
|
): { state: T; version: StateVersion } | null {
|
|
// Check if data is already versioned
|
|
if (
|
|
typeof data === 'object' &&
|
|
data !== null &&
|
|
'version' in data &&
|
|
'state' in data
|
|
) {
|
|
const versioned = data as VersionedState<T>;
|
|
return {
|
|
state: versioned.state as T,
|
|
version: versioned.version,
|
|
};
|
|
}
|
|
|
|
// If not versioned but is a valid object, assume version 1 (legacy state)
|
|
if (typeof data === 'object' && data !== null) {
|
|
return {
|
|
state: data as T,
|
|
version: 1,
|
|
};
|
|
}
|
|
|
|
// Invalid data
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Create versioned storage adapter for Zustand persist middleware
|
|
*/
|
|
export function createVersionedStorage<T = unknown>(
|
|
config: VersioningConfig<T>,
|
|
): {
|
|
getItem: (name: string) => string | null;
|
|
setItem: (name: string, value: string) => void;
|
|
removeItem: (name: string) => void;
|
|
} {
|
|
const { currentVersion, storeName, migrations, createInitialState } = config;
|
|
|
|
return {
|
|
getItem: (name: string): string | null => {
|
|
try {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const stored = localStorage.getItem(name);
|
|
if (!stored) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(stored);
|
|
const unversioned = unversionState<T>(parsed);
|
|
|
|
if (!unversioned) {
|
|
logger.warn(`[StateVersioning:${storeName}] Invalid stored state format.`);
|
|
if (createInitialState) {
|
|
const initialState = createInitialState();
|
|
return JSON.stringify(versionState(initialState, currentVersion));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const { state, version } = unversioned;
|
|
|
|
// If version matches, return as-is
|
|
if (version === currentVersion) {
|
|
return stored;
|
|
}
|
|
|
|
// Apply migrations
|
|
try {
|
|
const migratedState = applyMigrations(
|
|
{ version, state },
|
|
config,
|
|
);
|
|
|
|
// Save migrated state
|
|
const versioned = versionState(migratedState, currentVersion, {
|
|
migratedFrom: version,
|
|
});
|
|
localStorage.setItem(name, JSON.stringify(versioned));
|
|
|
|
logger.info(
|
|
`[StateVersioning:${storeName}] State migrated from version ${version} to ${currentVersion}.`,
|
|
);
|
|
|
|
return JSON.stringify(versioned);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[StateVersioning:${storeName}] Migration failed:`,
|
|
error,
|
|
);
|
|
|
|
if (createInitialState) {
|
|
try {
|
|
const initialState = createInitialState();
|
|
const versioned = versionState(initialState, currentVersion);
|
|
localStorage.setItem(name, JSON.stringify(versioned));
|
|
return JSON.stringify(versioned);
|
|
} catch (initError) {
|
|
logger.error(
|
|
`[StateVersioning:${storeName}] Failed to create initial state:`,
|
|
initError,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Return null if migration fails and no initial state
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
logger.error(`[StateVersioning:${storeName}] Failed to get item:`, error);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
setItem: (name: string, value: string): void => {
|
|
try {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
// Parse and version the state before storing
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch {
|
|
// If parsing fails, treat as plain string or use value as-is
|
|
parsed = value;
|
|
}
|
|
|
|
// Check if already versioned
|
|
const unversioned = unversionState(parsed);
|
|
if (unversioned && unversioned.version === currentVersion) {
|
|
// Already versioned and at current version, store as-is
|
|
const versioned = versionState(unversioned.state, currentVersion);
|
|
localStorage.setItem(name, JSON.stringify(versioned));
|
|
} else if (unversioned) {
|
|
// Versioned but different version, migrate first
|
|
const migratedState = applyMigrations(
|
|
{ version: unversioned.version, state: unversioned.state },
|
|
config,
|
|
);
|
|
const versioned = versionState(migratedState, currentVersion);
|
|
localStorage.setItem(name, JSON.stringify(versioned));
|
|
} else {
|
|
// Not versioned, version it
|
|
const versioned = versionState(parsed, currentVersion);
|
|
localStorage.setItem(name, JSON.stringify(versioned));
|
|
}
|
|
} catch (error) {
|
|
logger.error(`[StateVersioning:${storeName}] Failed to set item:`, error);
|
|
}
|
|
},
|
|
|
|
removeItem: (name: string): void => {
|
|
try {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
localStorage.removeItem(name);
|
|
} catch (error) {
|
|
logger.error(`[StateVersioning:${storeName}] Failed to remove item:`, error);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to create a migration
|
|
*/
|
|
export function createMigration<T = unknown>(
|
|
toVersion: StateVersion,
|
|
migrate: MigrationFunction<T>,
|
|
description?: string,
|
|
): Migration<T> {
|
|
return {
|
|
toVersion,
|
|
migrate,
|
|
description,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to create multiple migrations at once
|
|
*/
|
|
export function createMigrations<T = unknown>(
|
|
...migrations: Array<{
|
|
toVersion: StateVersion;
|
|
migrate: MigrationFunction<T>;
|
|
description?: string;
|
|
}>
|
|
): Migration<T>[] {
|
|
return migrations.map((m) => createMigration(m.toVersion, m.migrate, m.description));
|
|
}
|
|
|