Files
grimoire/src/lib/migrations.ts
Alejandro Gómez 2987a37e65 feat: spells
2025-12-20 14:25:40 +01:00

352 lines
9.2 KiB
TypeScript

/**
* State Migration System
*
* Handles schema version upgrades and state validation
* Ensures data integrity across application updates
*/
import { GrimoireState } from "@/types/app";
import { toast } from "sonner";
export const CURRENT_VERSION = 10;
/**
* Migration function type
*/
type MigrationFn = (state: any) => any;
/**
* Migration registry - add new migrations here
* Each migration transforms state from version N to N+1
*/
const migrations: Record<number, MigrationFn> = {
// Migration from v5 to v6 - adds __version field
5: (state: any) => {
return {
__version: 6,
...state,
};
},
// Migration from v6 to v7 - separates workspace number from label
6: (state: any) => {
const migratedWorkspaces: Record<string, any> = {};
// Convert each workspace from old format (label as string) to new format (number + optional label)
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
const ws = workspace as any;
// Try to parse the label as a number
const parsedNumber = parseInt(ws.label, 10);
if (!isNaN(parsedNumber)) {
// Label is numeric - use it as the number, no label
migratedWorkspaces[id] = {
...ws,
number: parsedNumber,
label: undefined,
};
} else {
// Label is not numeric - assign it the next available number, keep label
// Find the highest number used so far
const usedNumbers = Object.values(migratedWorkspaces).map(
(w: any) => w.number,
);
const maxNumber = usedNumbers.length > 0 ? Math.max(...usedNumbers) : 0;
migratedWorkspaces[id] = {
...ws,
number: maxNumber + 1,
label: ws.label,
};
}
}
return {
...state,
__version: 7,
workspaces: migratedWorkspaces,
};
},
// Migration from v7 to v8 - adds global layoutConfig
7: (state: any) => {
// Add global layoutConfig with smart defaults
return {
...state,
__version: 8,
layoutConfig: {
insertionMode: "smart", // Smart auto-balancing
splitPercentage: 50, // Equal split
insertionPosition: "second", // New windows on right/bottom
autoPreset: undefined, // No preset by default
},
};
},
// Migration from v8 to v9 - simplifies relay structure
8: (state: any) => {
// Simplify activeAccount.relays from {inbox, outbox, all} to just an array
// The 'all' array already has the correct read/write flags per relay
if (state.activeAccount?.relays) {
const oldRelays = state.activeAccount.relays;
// If it has the old structure (with inbox/outbox/all), migrate it
if (oldRelays.all && Array.isArray(oldRelays.all)) {
return {
...state,
__version: 9,
activeAccount: {
...state.activeAccount,
relays: oldRelays.all,
},
};
}
}
// No relays to migrate, just bump version
return {
...state,
__version: 9,
};
},
// Migration from v9 to v10 - adds compactModeKinds
9: (state: any) => {
return {
...state,
__version: 10,
compactModeKinds: [6, 7, 16, 9735],
};
},
};
/**
* Validate state structure
* Basic checks to ensure state is not corrupted
*/
export function validateState(state: any): state is GrimoireState {
try {
// Must be an object
if (!state || typeof state !== "object") {
return false;
}
// Must have required top-level fields
if (
!state.windows ||
!state.workspaces ||
!state.activeWorkspaceId ||
!state.layoutConfig ||
typeof state.__version !== "number"
) {
return false;
}
// layoutConfig must be an object
if (typeof state.layoutConfig !== "object") {
return false;
}
// compactModeKinds must be an array if present
if (state.compactModeKinds && !Array.isArray(state.compactModeKinds)) {
return false;
}
// Windows must be an object
if (typeof state.windows !== "object") {
return false;
}
// Workspaces must be an object
if (typeof state.workspaces !== "object") {
return false;
}
// Active workspace must exist
if (!state.workspaces[state.activeWorkspaceId]) {
return false;
}
// All window IDs in workspaces must exist in windows
for (const workspace of Object.values(state.workspaces)) {
const ws = workspace as any;
if (!Array.isArray(ws.windowIds)) {
return false;
}
for (const windowId of ws.windowIds) {
if (!state.windows[windowId]) {
return false;
}
}
}
return true;
} catch (error) {
console.error("[Migrations] Validation error:", error);
return false;
}
}
/**
* Migrate state from old version to current version
* Applies migrations sequentially
*/
export function migrateState(state: any): GrimoireState {
let currentState = state;
const startVersion = state.__version || 5; // Default to 5 if no version
console.log(
`[Migrations] Migrating from v${startVersion} to v${CURRENT_VERSION}`,
);
// Apply migrations sequentially
for (let version = startVersion; version < CURRENT_VERSION; version++) {
const migration = migrations[version];
if (migration) {
console.log(
`[Migrations] Applying migration v${version} -> v${version + 1}`,
);
try {
currentState = migration(currentState);
} catch (error) {
console.error(`[Migrations] Migration v${version} failed:`, error);
throw new Error(
`Failed to migrate from version ${version} to ${version + 1}`,
);
}
}
}
// Validate migrated state
if (!validateState(currentState)) {
throw new Error("Migrated state failed validation");
}
return currentState as GrimoireState;
}
/**
* Load state from localStorage with migration and validation
*/
export function loadStateWithMigration(
key: string,
initialState: GrimoireState,
): GrimoireState {
try {
const stored = localStorage.getItem(key);
if (!stored) {
return initialState;
}
const parsed = JSON.parse(stored);
// Check if migration is needed
const storedVersion = parsed.__version || 5;
if (storedVersion < CURRENT_VERSION) {
console.log(
`[Migrations] State version outdated (v${storedVersion}), migrating...`,
);
const migrated = migrateState(parsed);
// Save migrated state
localStorage.setItem(key, JSON.stringify(migrated));
toast.success("State Updated", {
description: `Migrated from v${storedVersion} to v${CURRENT_VERSION}`,
});
return migrated;
}
// Validate current version state
if (!validateState(parsed)) {
console.warn("[Migrations] State validation failed, using initial state");
toast.error("State Corrupted", {
description: "Your state was corrupted and has been reset.",
});
return initialState;
}
return parsed;
} catch (error) {
console.error("[Migrations] Failed to load state:", error);
toast.error("Failed to Load State", {
description: "Using default state. Your data may have been lost.",
});
return initialState;
}
}
/**
* Export state to JSON file
*/
export function exportState(state: GrimoireState): void {
try {
const json = JSON.stringify(state, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `grimoire-state-v${state.__version}-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("State Exported", {
description: "Your state has been downloaded as JSON",
});
} catch (error) {
console.error("[Migrations] Export failed:", error);
toast.error("Export Failed", {
description: "Could not export state",
});
}
}
/**
* Import state from JSON file
*/
export function importState(
file: File,
callback: (state: GrimoireState) => void,
): void {
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = e.target?.result as string;
const parsed = JSON.parse(json);
// Validate and migrate imported state
const storedVersion = parsed.__version || 5;
let finalState: GrimoireState;
if (storedVersion < CURRENT_VERSION) {
console.log(
`[Migrations] Imported state is v${storedVersion}, migrating...`,
);
finalState = migrateState(parsed);
} else if (!validateState(parsed)) {
throw new Error("Imported state failed validation");
} else {
finalState = parsed;
}
callback(finalState);
toast.success("State Imported", {
description: `Loaded state from v${storedVersion}`,
});
} catch (error) {
console.error("[Migrations] Import failed:", error);
toast.error("Import Failed", {
description: "Invalid or corrupted state file",
});
}
};
reader.onerror = () => {
toast.error("Import Failed", {
description: "Could not read file",
});
};
reader.readAsText(file);
}