veza/apps/web/src/utils/stateVersioning.ts
senke 6e13d2b71a [FE-STATE-011] fe-state: Add state versioning
- 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
2025-12-25 14:19:40 +01:00

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));
}