mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
feat: implement settings migration from localStorage to server
- Added logic to perform settings migration, merging localStorage data with server settings if necessary. - Introduced `localStorageMigrated` flag to prevent re-migration on subsequent app loads. - Updated `useSettingsMigration` hook to handle migration and hydration of settings. - Ensured localStorage values are preserved post-migration for user flexibility. - Enhanced documentation within the migration logic for clarity.
This commit is contained in:
@@ -7,9 +7,14 @@
|
|||||||
*
|
*
|
||||||
* Migration flow:
|
* Migration flow:
|
||||||
* 1. useSettingsMigration() hook fetches settings from the server API
|
* 1. useSettingsMigration() hook fetches settings from the server API
|
||||||
* 2. Merges localStorage data (if any) with server data, preferring more complete data
|
* 2. Checks if `localStorageMigrated` flag is true - if so, skips migration
|
||||||
* 3. Hydrates the Zustand store with the merged settings
|
* 3. If migration needed: merges localStorage data with server data, preferring more complete data
|
||||||
* 4. Returns a promise that resolves when hydration is complete
|
* 4. Sets `localStorageMigrated: true` in server settings to prevent re-migration
|
||||||
|
* 5. Hydrates the Zustand store with the merged/fetched settings
|
||||||
|
* 6. Returns a promise that resolves when hydration is complete
|
||||||
|
*
|
||||||
|
* IMPORTANT: localStorage values are intentionally NOT deleted after migration.
|
||||||
|
* This allows users to switch back to older versions of Automaker if needed.
|
||||||
*
|
*
|
||||||
* Sync functions for incremental updates:
|
* Sync functions for incremental updates:
|
||||||
* - syncSettingsToServer: Writes global settings to file
|
* - syncSettingsToServer: Writes global settings to file
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
||||||
import { getItem, removeItem, setItem } from '@/lib/storage';
|
import { getItem, setItem } from '@/lib/storage';
|
||||||
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
|
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import type { GlobalSettings } from '@automaker/types';
|
import type { GlobalSettings } from '@automaker/types';
|
||||||
@@ -50,18 +55,9 @@ const LOCALSTORAGE_KEYS = [
|
|||||||
'automaker:lastProjectDir',
|
'automaker:lastProjectDir',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
// NOTE: We intentionally do NOT clear any localStorage keys after migration.
|
||||||
* localStorage keys to remove after successful migration
|
// This allows users to switch back to older versions of Automaker that relied on localStorage.
|
||||||
*/
|
// The `localStorageMigrated` flag in server settings prevents re-migration on subsequent app loads.
|
||||||
const KEYS_TO_CLEAR_AFTER_MIGRATION = [
|
|
||||||
'worktree-panel-collapsed',
|
|
||||||
'file-browser-recent-folders',
|
|
||||||
'automaker:lastProjectDir',
|
|
||||||
'automaker_projects',
|
|
||||||
'automaker_current_project',
|
|
||||||
'automaker_trashed_projects',
|
|
||||||
'automaker-setup',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Global promise that resolves when migration is complete
|
// Global promise that resolves when migration is complete
|
||||||
// This allows useSettingsSync to wait for hydration before starting sync
|
// This allows useSettingsSync to wait for hydration before starting sync
|
||||||
@@ -101,7 +97,7 @@ export function waitForMigrationComplete(): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Parse localStorage data into settings object
|
* Parse localStorage data into settings object
|
||||||
*/
|
*/
|
||||||
function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||||
try {
|
try {
|
||||||
const automakerStorage = getItem('automaker-storage');
|
const automakerStorage = getItem('automaker-storage');
|
||||||
if (!automakerStorage) {
|
if (!automakerStorage) {
|
||||||
@@ -176,7 +172,7 @@ function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
* Check if localStorage has more complete data than server
|
* Check if localStorage has more complete data than server
|
||||||
* Returns true if localStorage has projects but server doesn't
|
* Returns true if localStorage has projects but server doesn't
|
||||||
*/
|
*/
|
||||||
function localStorageHasMoreData(
|
export function localStorageHasMoreData(
|
||||||
localSettings: Partial<GlobalSettings> | null,
|
localSettings: Partial<GlobalSettings> | null,
|
||||||
serverSettings: GlobalSettings | null
|
serverSettings: GlobalSettings | null
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -210,7 +206,7 @@ function localStorageHasMoreData(
|
|||||||
* Merge localStorage settings with server settings
|
* Merge localStorage settings with server settings
|
||||||
* Prefers server data, but uses localStorage for missing arrays/objects
|
* Prefers server data, but uses localStorage for missing arrays/objects
|
||||||
*/
|
*/
|
||||||
function mergeSettings(
|
export function mergeSettings(
|
||||||
serverSettings: GlobalSettings,
|
serverSettings: GlobalSettings,
|
||||||
localSettings: Partial<GlobalSettings> | null
|
localSettings: Partial<GlobalSettings> | null
|
||||||
): GlobalSettings {
|
): GlobalSettings {
|
||||||
@@ -292,6 +288,74 @@ function mergeSettings(
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform settings migration from localStorage to server (async function version)
|
||||||
|
*
|
||||||
|
* This is the core migration logic extracted for use outside of React hooks.
|
||||||
|
* Call this from __root.tsx during app initialization.
|
||||||
|
*
|
||||||
|
* @param serverSettings - Settings fetched from the server API
|
||||||
|
* @returns Promise resolving to the final settings to use (merged if migration needed)
|
||||||
|
*/
|
||||||
|
export async function performSettingsMigration(
|
||||||
|
serverSettings: GlobalSettings
|
||||||
|
): Promise<{ settings: GlobalSettings; migrated: boolean }> {
|
||||||
|
// Get localStorage data
|
||||||
|
const localSettings = parseLocalStorageSettings();
|
||||||
|
logger.info(
|
||||||
|
`localStorage has ${localSettings?.projects?.length ?? 0} projects, ${localSettings?.aiProfiles?.length ?? 0} profiles`
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`Server has ${serverSettings.projects?.length ?? 0} projects, ${serverSettings.aiProfiles?.length ?? 0} profiles`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if migration has already been completed
|
||||||
|
if (serverSettings.localStorageMigrated) {
|
||||||
|
logger.info('localStorage migration already completed, using server settings only');
|
||||||
|
return { settings: serverSettings, migrated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if localStorage has more data than server
|
||||||
|
if (localStorageHasMoreData(localSettings, serverSettings)) {
|
||||||
|
// First-time migration: merge localStorage data with server settings
|
||||||
|
const mergedSettings = mergeSettings(serverSettings, localSettings);
|
||||||
|
logger.info('Merged localStorage data with server settings (first-time migration)');
|
||||||
|
|
||||||
|
// Sync merged settings to server with migration marker
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const updates = {
|
||||||
|
...mergedSettings,
|
||||||
|
localStorageMigrated: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await api.settings.updateGlobal(updates);
|
||||||
|
if (result.success) {
|
||||||
|
logger.info('Synced merged settings to server with migration marker');
|
||||||
|
} else {
|
||||||
|
logger.warn('Failed to sync merged settings to server:', result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to sync merged settings:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { settings: mergedSettings, migrated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No migration needed, but mark as migrated to prevent future checks
|
||||||
|
if (!serverSettings.localStorageMigrated) {
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
await api.settings.updateGlobal({ localStorageMigrated: true });
|
||||||
|
logger.info('Marked settings as migrated (no data to migrate)');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to set migration marker:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { settings: serverSettings, migrated: false };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook to handle settings hydration from server on startup
|
* React hook to handle settings hydration from server on startup
|
||||||
*
|
*
|
||||||
@@ -369,19 +433,26 @@ export function useSettingsMigration(): MigrationState {
|
|||||||
let needsSync = false;
|
let needsSync = false;
|
||||||
|
|
||||||
if (serverSettings) {
|
if (serverSettings) {
|
||||||
// Check if we need to merge localStorage data
|
// Check if migration has already been completed
|
||||||
if (localStorageHasMoreData(localSettings, serverSettings)) {
|
if (serverSettings.localStorageMigrated) {
|
||||||
|
logger.info('localStorage migration already completed, using server settings only');
|
||||||
|
finalSettings = serverSettings;
|
||||||
|
// Don't set needsSync - no migration needed
|
||||||
|
} else if (localStorageHasMoreData(localSettings, serverSettings)) {
|
||||||
|
// First-time migration: merge localStorage data with server settings
|
||||||
finalSettings = mergeSettings(serverSettings, localSettings);
|
finalSettings = mergeSettings(serverSettings, localSettings);
|
||||||
needsSync = true;
|
needsSync = true;
|
||||||
logger.info('Merged localStorage data with server settings');
|
logger.info('Merged localStorage data with server settings (first-time migration)');
|
||||||
} else {
|
} else {
|
||||||
finalSettings = serverSettings;
|
finalSettings = serverSettings;
|
||||||
}
|
}
|
||||||
} else if (localSettings) {
|
} else if (localSettings) {
|
||||||
// No server settings, use localStorage
|
// No server settings, use localStorage (first run migration)
|
||||||
finalSettings = localSettings as GlobalSettings;
|
finalSettings = localSettings as GlobalSettings;
|
||||||
needsSync = true;
|
needsSync = true;
|
||||||
logger.info('Using localStorage settings (no server settings found)');
|
logger.info(
|
||||||
|
'Using localStorage settings (no server settings found - first-time migration)'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// No settings anywhere, use defaults
|
// No settings anywhere, use defaults
|
||||||
logger.info('No settings found, using defaults');
|
logger.info('No settings found, using defaults');
|
||||||
@@ -394,18 +465,19 @@ export function useSettingsMigration(): MigrationState {
|
|||||||
hydrateStoreFromSettings(finalSettings);
|
hydrateStoreFromSettings(finalSettings);
|
||||||
logger.info('Store hydrated with settings');
|
logger.info('Store hydrated with settings');
|
||||||
|
|
||||||
// If we merged data or used localStorage, sync to server
|
// If we merged data or used localStorage, sync to server with migration marker
|
||||||
if (needsSync) {
|
if (needsSync) {
|
||||||
try {
|
try {
|
||||||
const updates = buildSettingsUpdateFromStore();
|
const updates = buildSettingsUpdateFromStore();
|
||||||
|
// Mark migration as complete so we don't re-migrate on next app load
|
||||||
|
// This preserves localStorage values for users who want to downgrade
|
||||||
|
(updates as Record<string, unknown>).localStorageMigrated = true;
|
||||||
|
|
||||||
const result = await api.settings.updateGlobal(updates);
|
const result = await api.settings.updateGlobal(updates);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.info('Synced merged settings to server');
|
logger.info('Synced merged settings to server with migration marker');
|
||||||
|
// NOTE: We intentionally do NOT clear localStorage values
|
||||||
// Clear old localStorage keys after successful sync
|
// This allows users to switch back to older versions of Automaker
|
||||||
for (const key of KEYS_TO_CLEAR_AFTER_MIGRATION) {
|
|
||||||
removeItem(key);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Failed to sync merged settings to server:', result.error);
|
logger.warn('Failed to sync merged settings to server:', result.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
getServerUrlSync,
|
getServerUrlSync,
|
||||||
getHttpApiClient,
|
getHttpApiClient,
|
||||||
} from '@/lib/http-api-client';
|
} from '@/lib/http-api-client';
|
||||||
import { hydrateStoreFromSettings, signalMigrationComplete } from '@/hooks/use-settings-migration';
|
import {
|
||||||
|
hydrateStoreFromSettings,
|
||||||
|
signalMigrationComplete,
|
||||||
|
performSettingsMigration,
|
||||||
|
} from '@/hooks/use-settings-migration';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||||
@@ -252,13 +256,20 @@ function RootLayoutContent() {
|
|||||||
try {
|
try {
|
||||||
const settingsResult = await api.settings.getGlobal();
|
const settingsResult = await api.settings.getGlobal();
|
||||||
if (settingsResult.success && settingsResult.settings) {
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
// Hydrate store (including setupComplete)
|
// Perform migration from localStorage if needed (first-time migration)
|
||||||
// This function handles updating the store with all settings
|
// This checks if localStorage has projects/data that server doesn't have
|
||||||
// Cast through unknown first to handle type differences between API response and GlobalSettings
|
// and merges them before hydrating the store
|
||||||
hydrateStoreFromSettings(
|
const { settings: finalSettings, migrated } = await performSettingsMigration(
|
||||||
settingsResult.settings as unknown as Parameters<typeof hydrateStoreFromSettings>[0]
|
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (migrated) {
|
||||||
|
logger.info('Settings migration from localStorage completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate store with the final settings (merged if migration occurred)
|
||||||
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
|
||||||
// Signal that settings hydration is complete so useSettingsSync can start
|
// Signal that settings hydration is complete so useSettingsSync can start
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
|
||||||
|
|||||||
@@ -387,6 +387,10 @@ export interface GlobalSettings {
|
|||||||
/** Version number for schema migration */
|
/** Version number for schema migration */
|
||||||
version: number;
|
version: number;
|
||||||
|
|
||||||
|
// Migration Tracking
|
||||||
|
/** Whether localStorage settings have been migrated to API storage (prevents re-migration) */
|
||||||
|
localStorageMigrated?: boolean;
|
||||||
|
|
||||||
// Onboarding / Setup Wizard
|
// Onboarding / Setup Wizard
|
||||||
/** Whether the initial setup wizard has been completed */
|
/** Whether the initial setup wizard has been completed */
|
||||||
setupComplete: boolean;
|
setupComplete: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user