mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
352 lines
9.2 KiB
TypeScript
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);
|
|
}
|