refactor(settings): sequential settings migration

This commit is contained in:
mingholy.lmh 2026-02-28 18:13:25 +08:00
parent ac5a0c68e5
commit ae8c0d3d4e
18 changed files with 3527 additions and 944 deletions

View file

@ -0,0 +1,383 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
runMigrations,
needsMigration,
ALL_MIGRATIONS,
MigrationScheduler,
} from './index.js';
import { SETTINGS_VERSION } from '../settings.js';
describe('Migration Framework Integration', () => {
describe('runMigrations', () => {
it('should migrate V1 settings to V3', () => {
const v1Settings = {
theme: 'dark',
model: 'gemini',
disableAutoUpdate: true,
disableLoadingPhrases: false,
};
const result = runMigrations(v1Settings, 'user');
expect(result.finalVersion).toBe(3);
expect(result.executedMigrations).toHaveLength(2);
expect(result.executedMigrations[0]).toEqual({
fromVersion: 1,
toVersion: 2,
});
expect(result.executedMigrations[1]).toEqual({
fromVersion: 2,
toVersion: 3,
});
// Check V2 structure was created
const settings = result.settings as Record<string, unknown>;
expect(settings['$version']).toBe(3);
expect(settings['ui']).toEqual({
theme: 'dark',
accessibility: { enableLoadingPhrases: true },
});
expect(settings['model']).toEqual({ name: 'gemini' });
// Check disableAutoUpdate was inverted to enableAutoUpdate: false
expect(
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBe(false);
});
it('should migrate V2 settings to V3', () => {
const v2Settings = {
$version: 2,
ui: { theme: 'light' },
general: { disableAutoUpdate: false },
};
const result = runMigrations(v2Settings, 'user');
expect(result.finalVersion).toBe(3);
expect(result.executedMigrations).toHaveLength(1);
expect(result.executedMigrations[0]).toEqual({
fromVersion: 2,
toVersion: 3,
});
const settings = result.settings as Record<string, unknown>;
expect(settings['$version']).toBe(3);
expect(
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBe(true);
expect(
(settings['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBeUndefined();
});
it('should not modify V3 settings', () => {
const v3Settings = {
$version: 3,
ui: { theme: 'dark' },
general: { enableAutoUpdate: true },
};
const result = runMigrations(v3Settings, 'user');
expect(result.finalVersion).toBe(3);
expect(result.executedMigrations).toHaveLength(0);
expect(result.settings).toEqual(v3Settings);
});
it('should be idempotent', () => {
const v1Settings = {
theme: 'dark',
disableAutoUpdate: true,
};
const result1 = runMigrations(v1Settings, 'user');
const result2 = runMigrations(result1.settings, 'user');
expect(result1.executedMigrations).toHaveLength(2);
expect(result2.executedMigrations).toHaveLength(0);
expect(result1.finalVersion).toBe(result2.finalVersion);
});
});
describe('needsMigration', () => {
it('should return true for V1 settings', () => {
const v1Settings = {
theme: 'dark',
model: 'gemini',
};
expect(needsMigration(v1Settings)).toBe(true);
});
it('should return true for V2 settings with deprecated keys', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: true },
};
expect(needsMigration(v2Settings)).toBe(true);
});
it('should return true for V2 settings without deprecated keys', () => {
const cleanV2Settings = {
$version: 2,
ui: { theme: 'dark' },
};
// V2 settings should be migrated to V3 to update the version number
expect(needsMigration(cleanV2Settings)).toBe(true);
});
it('should return false for V3 settings', () => {
const v3Settings = {
$version: 3,
general: { enableAutoUpdate: true },
};
expect(needsMigration(v3Settings)).toBe(false);
});
it('should return false for legacy numeric version when no migration can execute', () => {
const legacyButUnknownSettings = {
$version: 1,
customOnlyKey: 'value',
};
expect(needsMigration(legacyButUnknownSettings)).toBe(false);
});
});
describe('ALL_MIGRATIONS', () => {
it('should contain all migrations in order', () => {
expect(ALL_MIGRATIONS).toHaveLength(2);
expect(ALL_MIGRATIONS[0].fromVersion).toBe(1);
expect(ALL_MIGRATIONS[0].toVersion).toBe(2);
expect(ALL_MIGRATIONS[1].fromVersion).toBe(2);
expect(ALL_MIGRATIONS[1].toVersion).toBe(3);
});
});
describe('MigrationScheduler with all migrations', () => {
it('should execute full migration chain', () => {
const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], 'user');
const v1Settings = {
theme: 'dark',
disableAutoUpdate: true,
disableLoadingPhrases: true,
};
const result = scheduler.migrate(v1Settings);
expect(result.executedMigrations).toHaveLength(2);
const settings = result.settings as Record<string, unknown>;
expect(settings['$version']).toBe(3);
expect((settings['ui'] as Record<string, unknown>)['theme']).toBe('dark');
expect(
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBe(false);
expect(
(
(settings['ui'] as Record<string, unknown>)[
'accessibility'
] as Record<string, unknown>
)['enableLoadingPhrases'],
).toBe(false);
});
});
describe('needsMigration and runMigrations consistency', () => {
it('needsMigration should return true when runMigrations would execute migrations', () => {
const v1Settings = {
theme: 'dark',
disableAutoUpdate: true,
};
// needsMigration should report that migration is needed
expect(needsMigration(v1Settings)).toBe(true);
// runMigrations should actually execute migrations
const result = runMigrations(v1Settings, 'user');
expect(result.executedMigrations.length).toBeGreaterThan(0);
});
it('needsMigration should return false when runMigrations would execute no migrations', () => {
const v3Settings = {
$version: 3,
general: { enableAutoUpdate: true },
};
// needsMigration should report that no migration is needed
expect(needsMigration(v3Settings)).toBe(false);
// runMigrations should execute no migrations
const result = runMigrations(v3Settings, 'user');
expect(result.executedMigrations).toHaveLength(0);
});
it('should handle V2 settings without deprecated keys consistently', () => {
const cleanV2Settings = {
$version: 2,
ui: { theme: 'dark' },
};
// needsMigration should report that migration is needed
expect(needsMigration(cleanV2Settings)).toBe(true);
// runMigrations should execute the V2->V3 migration
const result = runMigrations(cleanV2Settings, 'user');
expect(result.executedMigrations.length).toBeGreaterThan(0);
expect(result.finalVersion).toBe(3);
});
});
describe('migration chain integrity', () => {
it('should have strictly increasing versions (toVersion > fromVersion)', () => {
for (const migration of ALL_MIGRATIONS) {
expect(migration.toVersion).toBeGreaterThan(migration.fromVersion);
}
});
it('should have no gaps in the chain (adjacent versions)', () => {
for (let i = 1; i < ALL_MIGRATIONS.length; i++) {
const prevMigration = ALL_MIGRATIONS[i - 1];
const currMigration = ALL_MIGRATIONS[i];
expect(currMigration.fromVersion).toBe(prevMigration.toVersion);
}
});
it('should have no duplicate fromVersions', () => {
const fromVersions = ALL_MIGRATIONS.map((m) => m.fromVersion);
const uniqueFromVersions = new Set(fromVersions);
expect(uniqueFromVersions.size).toBe(fromVersions.length);
});
it('should have no duplicate toVersions', () => {
const toVersions = ALL_MIGRATIONS.map((m) => m.toVersion);
const uniqueToVersions = new Set(toVersions);
expect(uniqueToVersions.size).toBe(toVersions.length);
});
it('should be acyclic (no version appears as fromVersion more than once)', () => {
const fromVersionCounts = new Map<number, number>();
for (const migration of ALL_MIGRATIONS) {
const count = fromVersionCounts.get(migration.fromVersion) || 0;
fromVersionCounts.set(migration.fromVersion, count + 1);
}
for (const count of fromVersionCounts.values()) {
expect(count).toBe(1);
}
});
it('should chain from version 1 to SETTINGS_VERSION', () => {
if (ALL_MIGRATIONS.length > 0) {
expect(ALL_MIGRATIONS[0].fromVersion).toBe(1);
const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1];
expect(lastMigration.toVersion).toBe(SETTINGS_VERSION);
}
});
});
describe('single source of truth for version constant', () => {
it('should use SETTINGS_VERSION from settings module', () => {
// The last migration's toVersion should match SETTINGS_VERSION
const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1];
expect(lastMigration.toVersion).toBe(SETTINGS_VERSION);
});
it('needsMigration should use SETTINGS_VERSION for version comparison', () => {
// Create settings with version equal to SETTINGS_VERSION
const currentVersionSettings = {
$version: SETTINGS_VERSION,
general: { enableAutoUpdate: true },
};
// needsMigration should return false for current version
expect(needsMigration(currentVersionSettings)).toBe(false);
// Create settings with version less than SETTINGS_VERSION
const oldVersionSettings = {
$version: SETTINGS_VERSION - 1,
general: { disableAutoUpdate: true },
};
// needsMigration should return true for old version
expect(needsMigration(oldVersionSettings)).toBe(true);
});
it('should have SETTINGS_VERSION defined exactly once in codebase', () => {
// SETTINGS_VERSION is imported from settings.js
// This test verifies the wiring is correct
expect(SETTINGS_VERSION).toBeDefined();
expect(typeof SETTINGS_VERSION).toBe('number');
expect(SETTINGS_VERSION).toBeGreaterThan(0);
});
});
describe('invalid version handling', () => {
it('should treat non-numeric version with V1 shape as needing migration', () => {
const settingsWithInvalidVersion = {
$version: 'invalid',
theme: 'dark',
disableAutoUpdate: true,
};
// Should detect migration needed based on V1 shape
expect(needsMigration(settingsWithInvalidVersion)).toBe(true);
// Should run migrations
const result = runMigrations(settingsWithInvalidVersion, 'user');
expect(result.executedMigrations.length).toBeGreaterThan(0);
expect(result.finalVersion).toBe(SETTINGS_VERSION);
});
it('should not migrate non-numeric version with already-migrated shape (normalized by loader)', () => {
const settingsWithInvalidVersionButMigratedShape = {
$version: 'invalid',
general: { enableAutoUpdate: true },
};
// needsMigration returns false because no migration applies to this shape
// The settings loader will handle version normalization separately
expect(needsMigration(settingsWithInvalidVersionButMigratedShape)).toBe(
false,
);
// No migrations should execute
const result = runMigrations(
settingsWithInvalidVersionButMigratedShape,
'user',
);
expect(result.executedMigrations).toHaveLength(0);
});
it('should avoid repeated no-op migration loops', () => {
// Settings that might cause repeated migrations
const v3Settings = {
$version: 3,
general: { enableAutoUpdate: true },
};
// First check
expect(needsMigration(v3Settings)).toBe(false);
const result1 = runMigrations(v3Settings, 'user');
expect(result1.executedMigrations).toHaveLength(0);
// Second check should be consistent
expect(needsMigration(result1.settings)).toBe(false);
const result2 = runMigrations(result1.settings, 'user');
expect(result2.executedMigrations).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Export types
export type { SettingsMigration, MigrationResult } from './types.js';
// Export scheduler
export { MigrationScheduler } from './scheduler.js';
// Export migrations
export { v1ToV2Migration, V1ToV2Migration } from './versions/v1-to-v2.js';
export { v2ToV3Migration, V2ToV3Migration } from './versions/v2-to-v3.js';
// Import settings version from single source of truth
import { SETTINGS_VERSION } from '../settings.js';
// Ordered array of all migrations for use with MigrationScheduler
// Each migration handles one version transition (N → N+1)
// Order matters: migrations must be sorted by ascending version
import { v1ToV2Migration } from './versions/v1-to-v2.js';
import { v2ToV3Migration } from './versions/v2-to-v3.js';
import { MigrationScheduler } from './scheduler.js';
import type { MigrationResult } from './types.js';
/**
* Ordered array of all settings migrations.
* Use this with MigrationScheduler to run the full migration chain.
*
* @example
* ```typescript
* const scheduler = new MigrationScheduler(ALL_MIGRATIONS);
* const result = scheduler.migrate(settings);
* ```
*/
export const ALL_MIGRATIONS = [v1ToV2Migration, v2ToV3Migration] as const;
/**
* Convenience function that runs all migrations on the given settings.
* This is the primary entry point for settings migration.
*
* @param settings - The settings object to migrate
* @param scope - The scope of settings being migrated
* @returns MigrationResult containing the final settings, version, and execution log
*
* @example
* ```typescript
* const result = runMigrations(settings, 'User');
* if (result.executedMigrations.length > 0) {
* console.log(`Migrated from version ${result.executedMigrations[0].fromVersion} to ${result.finalVersion}`);
* }
* ```
*/
export function runMigrations(
settings: unknown,
scope: string,
): MigrationResult {
const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], scope);
return scheduler.migrate(settings);
}
/**
* Checks if the given settings need migration.
* Returns true only if at least one registered migration would be applied.
*
* This function checks:
* 1. If $version field exists and is a number:
* - Returns false if $version >= SETTINGS_VERSION
* - Returns true only when $version < SETTINGS_VERSION AND at least one
* migration can execute for the current settings shape
* 2. If $version field is missing or invalid:
* - Uses fallback logic by checking individual migrations
*
* Note:
* - Legacy numeric versions that have no executable migrations are handled by
* the settings loader via version normalization (bump metadata to current).
*
* @param settings - The settings object to check
* @returns true if migration is needed, false otherwise
*/
export function needsMigration(settings: unknown): boolean {
if (typeof settings !== 'object' || settings === null) {
return false;
}
const s = settings as Record<string, unknown>;
const version = s['$version'];
const hasApplicableMigration = ALL_MIGRATIONS.some((migration) =>
migration.shouldMigrate(settings),
);
// If $version is a valid number, use version comparison
if (typeof version === 'number') {
if (version >= SETTINGS_VERSION) {
return false;
}
// Guardrail: only report migration-needed if at least one migration can execute.
return hasApplicableMigration;
}
// If $version exists but is not a number (invalid), or is missing:
// Use fallback logic - check if any migration would be applied
return hasApplicableMigration;
}

View file

@ -0,0 +1,164 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { MigrationScheduler } from './scheduler.js';
import type { SettingsMigration } from './types.js';
describe('MigrationScheduler', () => {
// Mock migration for testing
const createMockMigration = (
fromVersion: number,
toVersion: number,
shouldMigrateResult: boolean,
): SettingsMigration => ({
fromVersion,
toVersion,
shouldMigrate: vi.fn().mockReturnValue(shouldMigrateResult),
migrate: vi.fn((settings) => ({
settings: {
...(settings as Record<string, unknown>),
$version: toVersion,
},
warnings: [],
})),
});
it('should execute migrations in order when shouldMigrate returns true', () => {
const migration1 = createMockMigration(1, 2, true);
const migration2 = createMockMigration(2, 3, true);
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
const result = scheduler.migrate({ $version: 1, someKey: 'value' });
expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1);
expect(migration1.migrate).toHaveBeenCalledTimes(1);
expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1);
expect(migration2.migrate).toHaveBeenCalledTimes(1);
expect(result.executedMigrations).toHaveLength(2);
expect(result.executedMigrations[0]).toEqual({
fromVersion: 1,
toVersion: 2,
});
expect(result.executedMigrations[1]).toEqual({
fromVersion: 2,
toVersion: 3,
});
expect(result.finalVersion).toBe(3);
});
it('should skip migrations when shouldMigrate returns false', () => {
const migration1 = createMockMigration(1, 2, false);
const migration2 = createMockMigration(2, 3, true);
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
const result = scheduler.migrate({ $version: 2, someKey: 'value' });
expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1);
expect(migration1.migrate).not.toHaveBeenCalled();
expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1);
expect(migration2.migrate).toHaveBeenCalledTimes(1);
expect(result.executedMigrations).toHaveLength(1);
expect(result.executedMigrations[0]).toEqual({
fromVersion: 2,
toVersion: 3,
});
});
it('should be idempotent - running migrations twice produces same result', () => {
// Create a migration that checks the version to determine if migration is needed
const migration1: SettingsMigration = {
fromVersion: 1,
toVersion: 2,
shouldMigrate: vi.fn((settings) => {
const s = settings as Record<string, unknown>;
return s['$version'] !== 2;
}),
migrate: vi.fn((settings) => ({
settings: {
...(settings as Record<string, unknown>),
$version: 2,
},
warnings: [],
})),
};
const scheduler = new MigrationScheduler([migration1], 'user');
const input = { theme: 'dark' };
const result1 = scheduler.migrate(input);
const result2 = scheduler.migrate(result1.settings);
expect(result1.executedMigrations).toHaveLength(1);
expect(result2.executedMigrations).toHaveLength(0);
expect(result1.finalVersion).toBe(result2.finalVersion);
});
it('should pass updated settings to each migration', () => {
const migration1: SettingsMigration = {
fromVersion: 1,
toVersion: 2,
shouldMigrate: vi.fn().mockReturnValue(true),
migrate: vi.fn(() => ({
settings: { $version: 2, transformed: true },
warnings: [],
})),
};
const migration2: SettingsMigration = {
fromVersion: 2,
toVersion: 3,
shouldMigrate: vi.fn().mockReturnValue(true),
migrate: vi.fn((s) => ({ settings: s, warnings: [] })),
};
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
scheduler.migrate({ $version: 1 });
expect(migration2.shouldMigrate).toHaveBeenCalledWith(
expect.objectContaining({ $version: 2, transformed: true }),
);
});
it('should handle empty migrations array', () => {
const scheduler = new MigrationScheduler([], 'user');
const result = scheduler.migrate({ $version: 1, key: 'value' });
expect(result.executedMigrations).toHaveLength(0);
expect(result.finalVersion).toBe(1);
expect(result.settings).toEqual({ $version: 1, key: 'value' });
});
it('should throw error when migration fails', () => {
const migration1: SettingsMigration = {
fromVersion: 1,
toVersion: 2,
shouldMigrate: vi.fn().mockReturnValue(true),
migrate: vi.fn().mockImplementation(() => {
throw new Error('Migration failed');
}),
};
const scheduler = new MigrationScheduler([migration1], 'user');
expect(() => scheduler.migrate({ $version: 1 })).toThrow(
'Migration failed',
);
});
it('should handle settings without version field', () => {
const migration1 = createMockMigration(1, 2, true);
const scheduler = new MigrationScheduler([migration1], 'user');
const result = scheduler.migrate({ theme: 'dark' });
expect(result.finalVersion).toBe(2);
expect(result.executedMigrations).toHaveLength(1);
});
});

View file

@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import type { SettingsMigration, MigrationResult } from './types.js';
const debugLogger = createDebugLogger('SETTINGS_MIGRATION');
/**
* Formats a SettingScope enum value to a human-readable string.
* - Converts to lowercase
* - Special case: 'SystemDefaults' -> 'system default'
*/
export function formatScope(scope: string): string {
if (scope === 'SystemDefaults') {
return 'system default';
}
return scope.toLowerCase();
}
/**
* Chain scheduler for settings migrations.
*
* The MigrationScheduler orchestrates multiple migrations in sequence,
* delegating version detection to each individual migration via `shouldMigrate`.
* It has no centralized version logic - migrations self-determine applicability.
*
* Key characteristics:
* - Linear chain execution: migrations are applied in registration order
* - Idempotent: already-migrated versions return false from shouldMigrate
* - Adjacent versions only: each migration handles N N+1
* - Pure functions: migrations don't modify input objects
*/
export class MigrationScheduler {
/**
* Creates a new MigrationScheduler with the given migrations.
*
* @param migrations - Array of migrations in execution order (typically ascending version)
* @param scope - The scope of settings being migrated
*/
constructor(
private readonly migrations: SettingsMigration[],
private readonly scope: string,
) {}
/**
* Executes the migration chain on the given settings.
*
* Iterates through all registered migrations in order. For each migration:
* 1. Calls `shouldMigrate` with the current settings
* 2. If true, calls `migrate` to transform the settings
* 3. Records the execution
*
* The scheduler itself has no version awareness - all version detection
* is delegated to the individual migrations.
*
* @param settings - The settings object to migrate
* @returns MigrationResult containing the final settings, version, and execution log
*/
migrate(settings: unknown): MigrationResult {
debugLogger.debug('MigrationScheduler: Starting migration chain');
let current = settings;
const executed: Array<{ fromVersion: number; toVersion: number }> = [];
const allWarnings: string[] = [];
for (const migration of this.migrations) {
try {
if (migration.shouldMigrate(current)) {
debugLogger.debug(
`MigrationScheduler: Executing migration ${migration.fromVersion}${migration.toVersion}`,
);
const formattedScope = formatScope(this.scope);
const result = migration.migrate(current, formattedScope);
current = result.settings;
allWarnings.push(...result.warnings);
executed.push({
fromVersion: migration.fromVersion,
toVersion: migration.toVersion,
});
debugLogger.debug(
`MigrationScheduler: Migration ${migration.fromVersion}${migration.toVersion} completed successfully`,
);
}
} catch (error) {
debugLogger.error(
`MigrationScheduler: Migration ${migration.fromVersion}${migration.toVersion} failed:`,
error,
);
throw error;
}
}
// Determine final version from the settings object
const finalVersion =
((current as Record<string, unknown>)['$version'] as number) ?? 1;
debugLogger.debug(
`MigrationScheduler: Migration chain complete. Final version: ${finalVersion}, Executed: ${executed.length} migrations`,
);
return {
settings: current,
finalVersion,
executedMigrations: executed,
warnings: allWarnings,
};
}
}

View file

@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Interface that all settings migrations must implement.
* Each migration handles a single version transition (N N+1).
*/
export interface SettingsMigration {
/** Source version number */
readonly fromVersion: number;
/** Target version number */
readonly toVersion: number;
/**
* Determines whether this migration should be applied to the given settings.
* The migration inspects the settings object to detect its current version
* and returns true if this migration is applicable.
*
* @param settings - The current settings object
* @returns true if this migration should be applied, false otherwise
*/
shouldMigrate(settings: unknown): boolean;
/**
* Executes the migration transformation.
* This should be a pure function that does not modify the input object.
*
* @param settings - The current settings object of version N
* @param scope - The scope of settings being migrated
* @returns The migrated settings object of version N+1 with optional warnings
* @throws Error if the migration fails
*/
migrate(
settings: unknown,
scope: string,
): { settings: unknown; warnings: string[] };
}
/**
* Result of a migration execution by MigrationScheduler.
*/
export interface MigrationResult {
/** The final settings object after all applicable migrations */
settings: unknown;
/** The final version number after migrations */
finalVersion: number;
/** List of migrations that were executed */
executedMigrations: Array<{ fromVersion: number; toVersion: number }>;
/** List of warning messages generated during migration */
warnings: string[];
}

View file

@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Structural mapping table for V1 -> V2.
*
* Used by:
* - v1->v2 migration execution
* - warnings for residual legacy keys in latest-version settings files
*/
export const V1_TO_V2_MIGRATION_MAP: Record<string, string> = {
accessibility: 'ui.accessibility',
allowedTools: 'tools.allowed',
allowMCPServers: 'mcp.allowed',
autoAccept: 'tools.autoAccept',
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
bugCommand: 'advanced.bugCommand',
chatCompression: 'model.chatCompression',
checkpointing: 'general.checkpointing',
coreTools: 'tools.core',
contextFileName: 'context.fileName',
customThemes: 'ui.customThemes',
customWittyPhrases: 'ui.customWittyPhrases',
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded',
excludedProjectEnvVars: 'advanced.excludedEnvVars',
extensions: 'extensions',
fileFiltering: 'context.fileFiltering',
folderTrustFeature: 'security.folderTrust.featureEnabled',
folderTrust: 'security.folderTrust.enabled',
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
hideWindowTitle: 'ui.hideWindowTitle',
showStatusInTitle: 'ui.showStatusInTitle',
hideTips: 'ui.hideTips',
showLineNumbers: 'ui.showLineNumbers',
showCitations: 'ui.showCitations',
ideMode: 'ide.enabled',
includeDirectories: 'context.includeDirectories',
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
maxSessionTurns: 'model.maxSessionTurns',
mcpServers: 'mcpServers',
mcpServerCommand: 'mcp.serverCommand',
memoryImportFormat: 'context.importFormat',
model: 'model.name',
preferredEditor: 'general.preferredEditor',
sandbox: 'tools.sandbox',
selectedAuthType: 'security.auth.selectedType',
shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell',
shellPager: 'tools.shell.pager',
shellShowColor: 'tools.shell.showColor',
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
summarizeToolOutput: 'model.summarizeToolOutput',
telemetry: 'telemetry',
theme: 'ui.theme',
toolDiscoveryCommand: 'tools.discoveryCommand',
toolCallCommand: 'tools.callCommand',
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
useExternalAuth: 'security.auth.useExternal',
useRipgrep: 'tools.useRipgrep',
vimMode: 'general.vimMode',
enableWelcomeBack: 'ui.enableWelcomeBack',
approvalMode: 'tools.approvalMode',
sessionTokenLimit: 'model.sessionTokenLimit',
contentGenerator: 'model.generationConfig',
skipLoopDetection: 'model.skipLoopDetection',
skipStartupContext: 'model.skipStartupContext',
enableOpenAILogging: 'model.enableOpenAILogging',
tavilyApiKey: 'advanced.tavilyApiKey',
};
/**
* Top-level keys that are V2/V3 containers.
* If one of these keys already has object value, treat it as latest-format data.
*/
export const V2_CONTAINER_KEYS = new Set([
'ui',
'tools',
'mcp',
'advanced',
'model',
'general',
'context',
'security',
'ide',
'privacy',
'telemetry',
'extensions',
]);
/**
* Legacy disable* keys that remain in disable* form for V2.
*/
export const V1_TO_V2_PRESERVE_DISABLE_MAP: Record<string, string> = {
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
disableLoadingPhrases: 'ui.accessibility.disableLoadingPhrases',
disableFuzzySearch: 'context.fileFiltering.disableFuzzySearch',
disableCacheControl: 'model.generationConfig.disableCacheControl',
};
export const CONSOLIDATED_DISABLE_KEYS = new Set([
'disableAutoUpdate',
'disableUpdateNag',
]);
/**
* Keys that indicate V1-like top-level structure when holding primitive values.
*/
export const V1_INDICATOR_KEYS = [
// From V1_TO_V2_MIGRATION_MAP - keys that map to different paths in V2
'theme',
'model',
'autoAccept',
'hideTips',
'vimMode',
'checkpointing',
'accessibility',
'allowedTools',
'allowMCPServers',
'autoConfigureMaxOldSpaceSize',
'bugCommand',
'chatCompression',
'coreTools',
'contextFileName',
'customThemes',
'customWittyPhrases',
'debugKeystrokeLogging',
'dnsResolutionOrder',
'enforcedAuthType',
'excludeTools',
'excludeMCPServers',
'excludedProjectEnvVars',
'fileFiltering',
'folderTrustFeature',
'folderTrust',
'hasSeenIdeIntegrationNudge',
'hideWindowTitle',
'showStatusInTitle',
'showLineNumbers',
'showCitations',
'ideMode',
'includeDirectories',
'loadMemoryFromIncludeDirectories',
'maxSessionTurns',
'mcpServerCommand',
'memoryImportFormat',
'preferredEditor',
'sandbox',
'selectedAuthType',
'shouldUseNodePtyShell',
'shellPager',
'shellShowColor',
'skipNextSpeakerCheck',
'summarizeToolOutput',
'toolDiscoveryCommand',
'toolCallCommand',
'usageStatisticsEnabled',
'useExternalAuth',
'useRipgrep',
'enableWelcomeBack',
'approvalMode',
'sessionTokenLimit',
'contentGenerator',
'skipLoopDetection',
'skipStartupContext',
'enableOpenAILogging',
'tavilyApiKey',
// From V1_TO_V2_PRESERVE_DISABLE_MAP - disable* keys that get nested in V2
'disableAutoUpdate',
'disableUpdateNag',
'disableLoadingPhrases',
'disableFuzzySearch',
'disableCacheControl',
];

View file

@ -0,0 +1,277 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { V1ToV2Migration } from './v1-to-v2.js';
describe('V1ToV2Migration', () => {
const migration = new V1ToV2Migration();
describe('shouldMigrate', () => {
it('should return true for V1 settings without version and with V1 keys', () => {
const v1Settings = {
theme: 'dark',
model: 'gemini',
};
expect(migration.shouldMigrate(v1Settings)).toBe(true);
});
it('should return true for V1 settings with disable* keys', () => {
const v1Settings = {
disableAutoUpdate: true,
disableLoadingPhrases: false,
};
expect(migration.shouldMigrate(v1Settings)).toBe(true);
});
it('should return false for settings with $version field', () => {
const v2Settings = {
$version: 2,
ui: { theme: 'dark' },
};
expect(migration.shouldMigrate(v2Settings)).toBe(false);
});
it('should return false for V3 settings', () => {
const v3Settings = {
$version: 3,
general: { enableAutoUpdate: true },
};
expect(migration.shouldMigrate(v3Settings)).toBe(false);
});
it('should return false for settings without V1 indicator keys', () => {
const unknownSettings = {
customKey: 'value',
anotherKey: 123,
};
expect(migration.shouldMigrate(unknownSettings)).toBe(false);
});
it('should return false for null input', () => {
expect(migration.shouldMigrate(null)).toBe(false);
});
it('should return false for non-object input', () => {
expect(migration.shouldMigrate('string')).toBe(false);
expect(migration.shouldMigrate(123)).toBe(false);
});
});
describe('migrate', () => {
it('should migrate flat V1 keys to nested V2 structure', () => {
const v1Settings = {
theme: 'dark',
model: 'gemini',
autoAccept: true,
hideTips: false,
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
expect(result['ui']).toEqual({ theme: 'dark', hideTips: false });
expect(result['model']).toEqual({ name: 'gemini' });
expect(result['tools']).toEqual({ autoAccept: true });
});
it('should migrate disable* keys to nested V2 paths without inversion', () => {
const v1Settings = {
theme: 'light',
disableAutoUpdate: true,
disableLoadingPhrases: false,
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
expect(result['general']).toEqual({ disableAutoUpdate: true });
expect(result['ui']).toEqual({
theme: 'light',
accessibility: { disableLoadingPhrases: false },
});
});
it('should normalize consolidated disable* non-boolean values to false', () => {
const v1Settings = {
theme: 'dark',
disableAutoUpdate: 'false',
disableUpdateNag: null,
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
expect(result['general']).toEqual({
disableAutoUpdate: false,
disableUpdateNag: false,
});
});
it('should drop non-boolean non-consolidated disable* values', () => {
const v1Settings = {
theme: 'dark',
disableLoadingPhrases: 'TRUE',
disableFuzzySearch: 1,
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
expect(
(result['ui'] as Record<string, unknown>)?.['accessibility'],
).toBeUndefined();
expect(
(
(result['context'] as Record<string, unknown>)?.[
'fileFiltering'
] as Record<string, unknown>
)?.['disableFuzzySearch'],
).toBeUndefined();
});
it('should preserve mcpServers at top level', () => {
const v1Settings = {
theme: 'dark',
mcpServers: {
myServer: { command: 'node', args: ['server.js'] },
},
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
expect(result['mcpServers']).toEqual({
myServer: { command: 'node', args: ['server.js'] },
});
});
it('should preserve unrecognized keys', () => {
const v1Settings = {
theme: 'dark',
myCustomSetting: 'value',
anotherCustom: 123,
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
expect(result['myCustomSetting']).toBe('value');
expect(result['anotherCustom']).toBe(123);
});
it('should preserve non-object parent path values on collision', () => {
const v1Settings = {
theme: 'dark',
disableAutoUpdate: true,
ui: 'legacy-ui-string',
general: 'legacy-general-string',
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
expect(result['ui']).toBe('legacy-ui-string');
expect(result['general']).toBe('legacy-general-string');
});
it('should not modify the input object', () => {
const v1Settings = {
theme: 'dark',
model: 'gemini',
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(v1Settings).toEqual({ theme: 'dark', model: 'gemini' });
expect(result).not.toBe(v1Settings);
});
it('should throw error for non-object input', () => {
expect(() => migration.migrate(null, 'user')).toThrow(
'Settings must be an object',
);
expect(() => migration.migrate('string', 'user')).toThrow(
'Settings must be an object',
);
});
it('should handle empty V1 settings', () => {
const v1Settings = {
theme: 'dark',
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
expect(result['ui']).toEqual({ theme: 'dark' });
});
it('should correctly handle all V1 indicator keys', () => {
const v1Settings = {
theme: 'dark',
model: 'gemini',
autoAccept: true,
hideTips: false,
vimMode: true,
checkpointing: false,
telemetry: {},
accessibility: {},
extensions: [],
mcpServers: {},
};
const { settings: result } = migration.migrate(v1Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(2);
});
});
describe('version properties', () => {
it('should have correct fromVersion', () => {
expect(migration.fromVersion).toBe(1);
});
it('should have correct toVersion', () => {
expect(migration.toVersion).toBe(2);
});
});
});

View file

@ -0,0 +1,299 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SettingsMigration } from '../types.js';
import {
CONSOLIDATED_DISABLE_KEYS,
V1_INDICATOR_KEYS,
V1_TO_V2_MIGRATION_MAP,
V1_TO_V2_PRESERVE_DISABLE_MAP,
V2_CONTAINER_KEYS,
} from './v1-to-v2-shared.js';
/**
* Heuristic indicators for deciding whether an object is "V1-like".
*
* Detection strategy:
* - A file is considered migratable as V1 when:
* 1) It is not explicitly versioned as V2+ (`$version` is missing or invalid), and
* 2) At least one indicator key appears in a legacy-compatible top-level shape.
* - Indicator list intentionally excludes keys that are valid top-level entries in
* both old and new structures to reduce false positives.
*
* Shape rule:
* - Object values for indicator keys are treated as already-nested V2-like content
* and do not alone trigger migration.
* - Primitive/array/null values on indicator keys are treated as legacy V1 signals.
*/
/**
* Best-effort nested setter with collision protection.
*
* Strategy:
* - Create missing intermediate objects while traversing the path.
* - If traversal hits a non-object parent, abort this write.
*
* Rationale:
* - Migration should never coerce scalars into objects implicitly because that can
* destroy user data shape. Skipping the write is safer; later carry-over logic
* preserves original values.
*/
function setNestedProperty(
obj: Record<string, unknown>,
path: string,
value: unknown,
): void {
const keys = path.split('.');
const lastKey = keys.pop();
if (!lastKey) return;
let current: Record<string, unknown> = obj;
for (const key of keys) {
if (current[key] === undefined) {
current[key] = {};
}
const next = current[key];
if (typeof next === 'object' && next !== null) {
current = next as Record<string, unknown>;
} else {
// Path collision with non-object, stop here
return;
}
}
current[lastKey] = value;
}
/**
* V1 -> V2 migration (structural normalization stage).
*
* Migration contract:
* - Input: settings in legacy V1-like shape (mostly flat, may contain mixed partial V2).
* - Output: V2-compatible nested structure with `$version: 2`.
* - No semantic inversion of disable* naming in this stage.
*
* Data-preservation strategy:
* - Prefer transforming known keys into canonical V2 locations.
* - Preserve unrecognized keys verbatim.
* - Preserve parent-path scalar values when nested writes would collide with them.
* - Preserve/merge existing partial V2 objects where safe.
*
* This class intentionally optimizes for backward compatibility and non-destructive
* behavior over aggressive normalization.
*/
export class V1ToV2Migration implements SettingsMigration {
readonly fromVersion = 1;
readonly toVersion = 2;
/**
* Determines whether this migration should execute.
*
* Decision strategy:
* - Hard-stop when `$version` is a number >= 2 (already V2+).
* - Otherwise, scan indicator keys and trigger only when at least one indicator is
* still in legacy top-level shape (primitive/array/null).
*
* Mixed-shape tolerance:
* - Files that are partially migrated are supported; V2-like object-valued indicators
* are ignored while legacy-shaped indicators can still trigger migration.
*/
shouldMigrate(settings: unknown): boolean {
if (typeof settings !== 'object' || settings === null) {
return false;
}
const s = settings as Record<string, unknown>;
// If $version exists and is a number >= 2, it's not V1
const version = s['$version'];
if (typeof version === 'number' && version >= 2) {
return false;
}
// Check for V1 indicator keys with primitive values
// A setting is considered V1 if ANY indicator key has a primitive value
// (string, number, boolean, null, or array) at the top level.
// Keys with object values are skipped as they may already be in V2 format.
return V1_INDICATOR_KEYS.some((key) => {
if (!(key in s)) {
return false;
}
const value = s[key];
// Skip keys with object values - they may already be in V2 nested format
// But don't let them block migration of other keys
if (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
) {
// This key appears to be in V2 format, skip it but continue
// checking other keys
return false;
}
// Found a key with primitive value - this is V1 format
return true;
});
}
/**
* Performs non-destructive V1 -> V2 transformation.
*
* Detailed strategy:
* 1) Relocate known V1 keys using `V1_TO_V2_MIGRATION_MAP`.
* - If a source value is already an object and maps to a child path of itself
* (partial V2 shape), merge child properties into target path.
* 2) Relocate disable* keys into V2 disable* locations.
* - Consolidated keys (`disableAutoUpdate`, `disableUpdateNag`): normalize to
* boolean with stable-compatible presence semantics (`value === true`).
* - Other disable* keys: migrate only boolean values.
* 3) Preserve `mcpServers` top-level placement.
* 4) Carry over remaining keys:
* - If a key is parent of migrated nested paths, merge unprocessed object children.
* - If parent value is non-object, preserve that scalar/array/null as-is.
* - Otherwise copy untouched key/value.
* 5) Stamp `$version = 2`.
*
* The method is pure with respect to input mutation.
*/
migrate(
settings: unknown,
_scope: string,
): { settings: unknown; warnings: string[] } {
if (typeof settings !== 'object' || settings === null) {
throw new Error('Settings must be an object');
}
const source = settings as Record<string, unknown>;
const result: Record<string, unknown> = {};
const processedKeys = new Set<string>();
const warnings: string[] = [];
// Step 1: Map known V1 keys to V2 nested paths
for (const [v1Key, v2Path] of Object.entries(V1_TO_V2_MIGRATION_MAP)) {
if (v1Key in source) {
const value = source[v1Key];
// Safety check: If this key is a V2 container (like 'model') and it's
// already an object, it's likely already in V2 format. Skip migration
// to prevent double-nesting (e.g., model.name.name).
if (
V2_CONTAINER_KEYS.has(v1Key) &&
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
) {
// This is already a V2 container, carry it over as-is
result[v1Key] = value;
processedKeys.add(v1Key);
continue;
}
// If value is already an object and the path matches the key,
// it might be a partial V2 structure. Merge its contents.
if (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
v2Path.startsWith(v1Key + '.')
) {
// Merge nested properties from this partial V2 structure
for (const [nestedKey, nestedValue] of Object.entries(value)) {
setNestedProperty(result, `${v2Path}.${nestedKey}`, nestedValue);
}
} else {
setNestedProperty(result, v2Path, value);
}
processedKeys.add(v1Key);
}
}
// Step 2: Map V1 disable* keys to V2 nested disable* paths
for (const [v1Key, v2Path] of Object.entries(
V1_TO_V2_PRESERVE_DISABLE_MAP,
)) {
if (v1Key in source) {
const value = source[v1Key];
if (CONSOLIDATED_DISABLE_KEYS.has(v1Key)) {
// Preserve stable behavior: consolidated keys use presence semantics.
// Only literal true remains true; all other present values become false.
setNestedProperty(result, v2Path, value === true);
} else if (typeof value === 'boolean') {
// Non-consolidated disable* keys only migrate when explicitly boolean.
setNestedProperty(result, v2Path, value);
}
processedKeys.add(v1Key);
}
}
// Step 3: Preserve mcpServers at the top level
if ('mcpServers' in source) {
result['mcpServers'] = source['mcpServers'];
processedKeys.add('mcpServers');
}
// Step 4: Carry over any unrecognized keys (including unknown nested objects)
// Important: Skip keys that are parent paths of already-migrated properties
// to avoid overwriting merged structures (e.g., 'ui' should not overwrite 'ui.theme')
for (const key of Object.keys(source)) {
if (!processedKeys.has(key)) {
// Check if this key is a parent of any already-migrated path
const isParentOfMigratedPath = Array.from(processedKeys).some(
(processedKey) => {
// Get the v2 path for this processed key
const v2Path =
V1_TO_V2_MIGRATION_MAP[processedKey] ||
V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey];
if (!v2Path) return false;
// Check if the v2 path starts with this key + '.'
return v2Path.startsWith(key + '.');
},
);
if (isParentOfMigratedPath) {
// This key is a parent of an already-migrated path
// Merge its unprocessed children instead of overwriting
const existingValue = source[key];
if (
typeof existingValue === 'object' &&
existingValue !== null &&
!Array.isArray(existingValue)
) {
for (const [nestedKey, nestedValue] of Object.entries(
existingValue,
)) {
// Only merge if this nested key wasn't already processed
const fullNestedPath = `${key}.${nestedKey}`;
const wasProcessed = Array.from(processedKeys).some(
(processedKey) => {
const v2Path =
V1_TO_V2_MIGRATION_MAP[processedKey] ||
V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey];
return v2Path === fullNestedPath;
},
);
if (!wasProcessed) {
setNestedProperty(result, fullNestedPath, nestedValue);
}
}
} else {
// Preserve non-object parent values to match legacy overwrite semantics.
result[key] = source[key];
}
} else {
// Not a parent path, safe to copy as-is
result[key] = source[key];
}
}
}
// Step 5: Set version to 2
result['$version'] = 2;
return { settings: result, warnings };
}
}
/** Singleton instance of V1→V2 migration */
export const v1ToV2Migration = new V1ToV2Migration();

View file

@ -0,0 +1,549 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { V2ToV3Migration } from './v2-to-v3.js';
describe('V2ToV3Migration', () => {
const migration = new V2ToV3Migration();
describe('shouldMigrate', () => {
it('should return true for V2 settings with deprecated disable* keys', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: true },
};
expect(migration.shouldMigrate(v2Settings)).toBe(true);
});
it('should return true for V2 settings with ui.accessibility.disableLoadingPhrases', () => {
const v2Settings = {
$version: 2,
ui: { accessibility: { disableLoadingPhrases: false } },
};
expect(migration.shouldMigrate(v2Settings)).toBe(true);
});
it('should return false for V3 settings', () => {
const v3Settings = {
$version: 3,
general: { enableAutoUpdate: true },
};
expect(migration.shouldMigrate(v3Settings)).toBe(false);
});
it('should return false for V1 settings without version', () => {
const v1Settings = {
theme: 'dark',
disableAutoUpdate: true,
};
expect(migration.shouldMigrate(v1Settings)).toBe(false);
});
it('should return true for V2 settings without deprecated keys', () => {
const cleanV2Settings = {
$version: 2,
ui: { theme: 'dark' },
general: { enableAutoUpdate: true },
};
// V2 settings should always be migrated to V3 to update the version number
expect(migration.shouldMigrate(cleanV2Settings)).toBe(true);
});
it('should return false for null input', () => {
expect(migration.shouldMigrate(null)).toBe(false);
});
it('should return false for non-object input', () => {
expect(migration.shouldMigrate('string')).toBe(false);
expect(migration.shouldMigrate(123)).toBe(false);
});
});
describe('migrate', () => {
it('should migrate disableAutoUpdate to enableAutoUpdate with inverted value', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: true },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBe(false);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBeUndefined();
});
it('should migrate disableLoadingPhrases to enableLoadingPhrases', () => {
const v2Settings = {
$version: 2,
ui: { accessibility: { disableLoadingPhrases: true } },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['ui'] as Record<string, unknown>)['accessibility'],
).toEqual({
enableLoadingPhrases: false,
});
});
it('should migrate disableFuzzySearch to enableFuzzySearch', () => {
const v2Settings = {
$version: 2,
context: { fileFiltering: { disableFuzzySearch: false } },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['context'] as Record<string, unknown>)['fileFiltering'],
).toEqual({
enableFuzzySearch: true,
});
});
it('should migrate disableCacheControl to enableCacheControl', () => {
const v2Settings = {
$version: 2,
model: { generationConfig: { disableCacheControl: true } },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['model'] as Record<string, unknown>)['generationConfig'],
).toEqual({
enableCacheControl: false,
});
});
it('should handle consolidated disableAutoUpdate and disableUpdateNag', () => {
const v2Settings = {
$version: 2,
general: {
disableAutoUpdate: true,
disableUpdateNag: false,
},
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
// If ANY disable* is true, enable should be false
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBe(false);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBeUndefined();
expect(
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
).toBeUndefined();
});
it('should set enableAutoUpdate to true when both disable* are false', () => {
const v2Settings = {
$version: 2,
general: {
disableAutoUpdate: false,
disableUpdateNag: false,
},
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBe(true);
});
it('should preserve other settings during migration', () => {
const v2Settings = {
$version: 2,
ui: {
theme: 'dark',
accessibility: { disableLoadingPhrases: true },
},
model: {
name: 'gemini',
},
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect((result['ui'] as Record<string, unknown>)['theme']).toBe('dark');
expect((result['model'] as Record<string, unknown>)['name']).toBe(
'gemini',
);
expect(
(result['ui'] as Record<string, unknown>)['accessibility'],
).toEqual({
enableLoadingPhrases: false,
});
});
it('should not modify the input object', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: true },
};
const result = migration.migrate(v2Settings, 'user');
expect(v2Settings.general).toEqual({ disableAutoUpdate: true });
expect(result).not.toBe(v2Settings);
});
it('should throw error for non-object input', () => {
expect(() => migration.migrate(null, 'user')).toThrow(
'Settings must be an object',
);
expect(() => migration.migrate('string', 'user')).toThrow(
'Settings must be an object',
);
});
it('should handle multiple deprecated keys in one migration', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: false },
ui: { accessibility: { disableLoadingPhrases: false } },
context: { fileFiltering: { disableFuzzySearch: false } },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBe(true);
expect(
(result['ui'] as Record<string, unknown>)['accessibility'],
).toEqual({
enableLoadingPhrases: true,
});
expect(
(result['context'] as Record<string, unknown>)['fileFiltering'],
).toEqual({
enableFuzzySearch: true,
});
});
it('should preserve string "true" without creating enable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: 'true' },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBe('true');
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBeUndefined();
});
it('should preserve string "false" without creating enable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: 'false' },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBe('false');
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBeUndefined();
});
it('should preserve string "TRUE" without creating enable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: 'TRUE' },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBe('TRUE');
});
it('should preserve string "FALSE" without creating enable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: 'FALSE' },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBe('FALSE');
});
it('should preserve number value and not create enable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: 123 },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBe(123);
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBeUndefined();
});
it('should preserve invalid string value and not create enable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: 'invalid-string' },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBe('invalid-string');
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBeUndefined();
});
it('should preserve disableCacheControl string "true"', () => {
const v2Settings = {
$version: 2,
model: { generationConfig: { disableCacheControl: 'true' } },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['model'] as Record<string, unknown>)['generationConfig'],
).toEqual({
disableCacheControl: 'true',
});
});
it('should preserve disableCacheControl string "false"', () => {
const v2Settings = {
$version: 2,
model: { generationConfig: { disableCacheControl: 'false' } },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['model'] as Record<string, unknown>)['generationConfig'],
).toEqual({
disableCacheControl: 'false',
});
});
it('should preserve disableCacheControl number value and not create enable key', () => {
const v2Settings = {
$version: 2,
model: { generationConfig: { disableCacheControl: 456 } },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['model'] as Record<string, unknown>)['generationConfig'],
).toEqual({ disableCacheControl: 456 });
expect(
(
(result['model'] as Record<string, unknown>)[
'generationConfig'
] as Record<string, unknown>
)['enableCacheControl'],
).toBeUndefined();
});
it('should handle mixed valid and invalid disableAutoUpdate and disableUpdateNag', () => {
const v2Settings = {
$version: 2,
general: {
disableAutoUpdate: true,
disableUpdateNag: 'invalid',
},
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
// Only valid values should contribute to the consolidated result
// Since disableAutoUpdate is true, enableAutoUpdate should be false
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBe(false);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBeUndefined();
expect(
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
).toBe('invalid');
});
it('should preserve object value for disable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: { nested: 'value' } },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toEqual({ nested: 'value' });
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBeUndefined();
});
it('should preserve array value for disable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: [1, 2, 3] },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toEqual([1, 2, 3]);
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBeUndefined();
});
it('should preserve null value for disable key', () => {
const v2Settings = {
$version: 2,
general: { disableAutoUpdate: null },
};
const { settings: result } = migration.migrate(v2Settings, 'user') as {
settings: Record<string, unknown>;
warnings: unknown[];
};
expect(result['$version']).toBe(3);
expect(
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
).toBeNull();
expect(
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
).toBeUndefined();
});
});
describe('version properties', () => {
it('should have correct fromVersion', () => {
expect(migration.fromVersion).toBe(2);
});
it('should have correct toVersion', () => {
expect(migration.toVersion).toBe(3);
});
});
});

View file

@ -0,0 +1,241 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SettingsMigration } from '../types.js';
/**
* Path mapping for boolean polarity migration (V2 disable* -> V3 enable*).
*
* Strategy:
* - For each mapped path, only boolean inputs are transformed.
* - Transformation is inversion-based: disable=true -> enable=false, disable=false -> enable=true.
* - Non-boolean values are intentionally ignored here to preserve stable compatibility.
*/
const V2_TO_V3_BOOLEAN_MAP: Record<string, string> = {
'general.disableAutoUpdate': 'general.enableAutoUpdate',
'general.disableUpdateNag': 'general.enableAutoUpdate',
'ui.accessibility.disableLoadingPhrases':
'ui.accessibility.enableLoadingPhrases',
'context.fileFiltering.disableFuzzySearch':
'context.fileFiltering.enableFuzzySearch',
'model.generationConfig.disableCacheControl':
'model.generationConfig.enableCacheControl',
};
/**
* Consolidated old paths that collapse into one V3 field.
*
* Current policy:
* - `general.disableAutoUpdate` and `general.disableUpdateNag` both drive
* `general.enableAutoUpdate`.
* - If any observed boolean source is true, target becomes false.
* - If no boolean source exists, consolidation does not emit a target value.
*/
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
'general.enableAutoUpdate': [
'general.disableAutoUpdate',
'general.disableUpdateNag',
],
};
/**
* Safe nested getter used during migration path inspection.
*
* Returns `undefined` when traversal cannot continue (missing key or non-object parent).
*/
function getNestedProperty(
obj: Record<string, unknown>,
path: string,
): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (typeof current !== 'object' || current === null || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
/**
* Safe nested setter used for emitting migrated paths.
*
* Behavior:
* - Creates intermediate objects when absent.
* - Aborts write if a parent segment is a non-object (collision protection).
*/
function setNestedProperty(
obj: Record<string, unknown>,
path: string,
value: unknown,
): void {
const keys = path.split('.');
const lastKey = keys.pop();
if (!lastKey) return;
let current: Record<string, unknown> = obj;
for (const key of keys) {
if (current[key] === undefined) {
current[key] = {};
}
const next = current[key];
if (typeof next === 'object' && next !== null) {
current = next as Record<string, unknown>;
} else {
// Path collision with non-object, stop here
return;
}
}
current[lastKey] = value;
}
/**
* Best-effort nested delete for removing deprecated disable* keys.
*
* If traversal hits a non-object parent, deletion is skipped.
*/
function deleteNestedProperty(
obj: Record<string, unknown>,
path: string,
): void {
const keys = path.split('.');
const lastKey = keys.pop();
if (!lastKey) return;
let current: Record<string, unknown> = obj;
for (const key of keys) {
const next = current[key];
if (typeof next !== 'object' || next === null) {
return;
}
current = next as Record<string, unknown>;
}
delete current[lastKey];
}
/**
* JSON-based deep clone used to keep `migrate()` input immutable.
*/
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
/**
* V2 -> V3 migration (boolean polarity normalization stage).
*
* Migration contract:
* - Input: V2 settings object (`$version: 2`).
* - Output: `$version: 3` with boolean disable* fields migrated to enable* equivalents.
*
* Compatibility strategy:
* - Transform only boolean-valued deprecated fields.
* - Preserve non-boolean deprecated values untouched.
* - Always bump version to 3 so future loads are idempotent and skip repeated checks.
*
* This implementation mirrors stable behavior and prioritizes parity over aggressive cleanup.
*/
export class V2ToV3Migration implements SettingsMigration {
readonly fromVersion = 2;
readonly toVersion = 3;
/**
* Migration trigger rule.
*
* Execute only when `$version === 2`.
* This includes V2 files with no migratable disable* booleans so that version
* metadata still advances to 3.
*/
shouldMigrate(settings: unknown): boolean {
if (typeof settings !== 'object' || settings === null) {
return false;
}
const s = settings as Record<string, unknown>;
// Migrate if $version is 2
return s['$version'] === 2;
}
/**
* Applies V2 -> V3 transformation with stable-compatible rules.
*
* Detailed strategy:
* 1) Clone input.
* 2) Process consolidated paths first:
* - Inspect each source path.
* - If value is boolean, consume it (delete old key) and contribute to aggregate.
* - Emit consolidated target when at least one boolean source was consumed.
* 3) Process remaining one-to-one mappings:
* - For each unmapped source, if boolean -> delete old key and write inverted target.
* - If non-boolean -> keep old value unchanged.
* 4) Set `$version = 3`.
*
* Guarantees:
* - Input object is not mutated.
* - Boolean migration is deterministic.
* - Non-boolean legacy values are preserved for compatibility.
*/
migrate(
settings: unknown,
_scope: string,
): { settings: unknown; warnings: string[] } {
if (typeof settings !== 'object' || settings === null) {
throw new Error('Settings must be an object');
}
// Deep clone to avoid mutating input
const result = deepClone(settings) as Record<string, unknown>;
const processedPaths = new Set<string>();
const warnings: string[] = [];
// Step 1: Handle consolidated paths (multiple old paths → single new path)
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
let hasAnyDisable = false;
let hasAnyBooleanValue = false;
for (const oldPath of oldPaths) {
const oldValue = getNestedProperty(result, oldPath);
if (typeof oldValue === 'boolean') {
hasAnyBooleanValue = true;
if (oldValue === true) {
hasAnyDisable = true;
}
deleteNestedProperty(result, oldPath);
processedPaths.add(oldPath);
}
}
if (hasAnyBooleanValue) {
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
setNestedProperty(result, newPath, !hasAnyDisable);
}
}
// Step 2: Handle remaining individual disable* → enable* mappings
for (const [oldPath, newPath] of Object.entries(V2_TO_V3_BOOLEAN_MAP)) {
if (processedPaths.has(oldPath)) {
continue;
}
const oldValue = getNestedProperty(result, oldPath);
if (typeof oldValue === 'boolean') {
deleteNestedProperty(result, oldPath);
// Set new property with inverted value
setNestedProperty(result, newPath, !oldValue);
}
}
// Step 3: Always update version to 3
result['$version'] = 3;
return { settings: result, warnings };
}
}
/** Singleton instance of V2→V3 migration */
export const v2ToV3Migration = new V2ToV3Migration();