Migration
The migration module (src/shared/lib/migration.ts) provides a robust system for managing storage schema changes across extension versions, ensuring seamless upgrades for users.
Overview
The migration system allows you to:
- Version-based Migrations — Run transformations when upgrading
- Automatic Execution — Runs on install/update events
- Storage Transformation — Update data schema safely
- Version Tracking — Store current version in sync storage
- Rollback Safety — Skip already-applied migrations
Architecture
Migration Flow
Defining Migrations
Migration Interface
export interface Migration {
version: Version; // Target version (e.g., "1.1.0")
description: string; // Human-readable description
migrate: MigrationFn; // Transformation function
}
export type MigrationFn = (
context: MigrationContext
) => Promise<void | MigrationResult>;
Migration Context
The migration function receives a context object:
export interface MigrationContext {
currentVersion: Version; // Current extension version
storedVersion: Version | null; // Previously stored version
// Read single storage value
getStorage: <T = unknown>(
area: 'sync' | 'local',
key: string
) => Promise<T | undefined>;
// Read all storage values
getAllStorage: (
area: 'sync' | 'local'
) => Promise<Record<string, unknown>>;
}
Migration Result
Return storage updates to apply:
export interface MigrationResult {
sync?: Record<string, any>; // Updates for sync storage
local?: Record<string, any>; // Updates for local storage
}
Configuration
Define migrations in src/shared/config.ts:
import type { Migration } from '@/shared/lib/migration';
export const customMigrations: Migration[] = [
{
version: '1.1.0',
description: 'Migrate old settings format to new structure',
migrate: async (ctx) => {
const oldSettings = await ctx.getStorage('sync', 'oldSettings');
if (oldSettings) {
return {
sync: {
settings: transformSettings(oldSettings)
}
};
}
}
},
{
version: '1.2.0',
description: 'Add default language preference',
migrate: async (ctx) => {
const settings = await ctx.getStorage('sync', 'settings');
if (settings && !settings.language) {
return {
sync: {
settings: { ...settings, language: 'en' }
}
};
}
}
},
{
version: '1.3.0',
description: 'Migrate local cache to new format',
migrate: async (ctx) => {
const cache = await ctx.getStorage('local', 'cache');
if (cache) {
return {
local: {
cache: {
version: 2,
data: cache.items || [],
timestamp: Date.now()
}
}
};
}
}
}
];
Running Migrations
Automatic Execution
Migrations run automatically on extension install/update:
import { runMigrations } from '@/shared/lib/migration';
import { logger } from '@/shared/lib/logger';
chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason === 'install' || details.reason === 'update') {
try {
await runMigrations();
logger.info('Migrations completed successfully');
} catch (err) {
logger.error('Migration failed:', err);
}
}
});
Manual Execution
You can also run migrations manually:
import { runMigrations } from '@/shared/lib/migration';
await runMigrations();
Migration Examples
Example 1: Rename Keys
{
version: '1.1.0',
description: 'Rename "theme" to "appearance"',
migrate: async (ctx) => {
const theme = await ctx.getStorage('sync', 'theme');
if (theme !== undefined) {
return {
sync: {
appearance: theme,
theme: undefined // Remove old key
}
};
}
}
}
Example 2: Transform Data Structure
{
version: '1.2.0',
description: 'Convert settings array to object',
migrate: async (ctx) => {
const settings = await ctx.getStorage<string[]>('sync', 'settings');
if (Array.isArray(settings)) {
const newSettings = settings.reduce((acc, item, idx) => {
acc[`setting_${idx}`] = item;
return acc;
}, {});
return {
sync: {
settings: newSettings
}
};
}
}
}
Example 3: Add Default Values
{
version: '1.3.0',
description: 'Add default notification settings',
migrate: async (ctx) => {
const settings = await ctx.getStorage('sync', 'settings');
if (settings && !settings.notifications) {
return {
sync: {
settings: {
...settings,
notifications: {
enabled: true,
sound: true,
frequency: 'daily'
}
}
}
};
}
}
}
Example 4: Clean Up Old Data
{
version: '1.4.0',
description: 'Remove deprecated cache entries',
migrate: async (ctx) => {
const allLocal = await ctx.getAllStorage('local');
const cleaned = Object.entries(allLocal).reduce((acc, [key, value]) => {
if (!key.startsWith('deprecated_')) {
acc[key] = value;
}
return acc;
}, {} as Record<string, any>);
return { local: cleaned };
}
}
Example 5: Conditional Migration
{
version: '1.5.0',
description: 'Migrate only if feature was enabled',
migrate: async (ctx) => {
const featureEnabled = await ctx.getStorage('local', 'featureEnabled');
if (featureEnabled) {
const oldData = await ctx.getStorage('local', 'featureData');
return {
local: {
newFeatureData: transformFeatureData(oldData)
}
};
}
// Return nothing if feature was not enabled
}
}
Version Comparison
The migration system uses semantic versioning for comparison:
// Versions are compared as [major, minor, patch]
"1.0.0" < "1.0.1" < "1.1.0" < "2.0.0"
Handling Pre-release Versions
The system supports basic semver:
parseVersion("1.2.3") // [1, 2, 3]
parseVersion("2.0") // [2, 0, 0]
parseVersion("1.0.0-beta") // [1, 0, 0] (suffix ignored)
Best Practices
1. Version Your Migrations
Always specify the target version:
{
version: '1.2.0', // ✓ Clear target version
description: 'Add feature X',
migrate: async (ctx) => { /* ... */ }
}
2. Provide Clear Descriptions
{
version: '1.3.0',
description: 'Migrate user preferences from array to object format', // ✓ Clear
migrate: async (ctx) => { /* ... */ }
}
3. Handle Missing Data
Always check if data exists before transforming:
migrate: async (ctx) => {
const data = await ctx.getStorage('sync', 'myData');
if (!data) {
return; // ✓ Safe: no-op if data doesn't exist
}
// Transform data...
}
4. Return Undefined for Keys to Delete
return {
sync: {
oldKey: undefined, // ✓ Removes the key
newKey: value
}
};
5. Test Migrations
Test migration logic before deployment:
import { describe, it, expect } from 'vitest';
import { customMigrations } from '@/shared/config';
describe('Migrations', () => {
it('should transform settings correctly', async () => {
const migration = customMigrations.find(m => m.version === '1.1.0');
const ctx = {
currentVersion: '1.1.0',
storedVersion: '1.0.0',
getStorage: async () => ({ oldFormat: true }),
getAllStorage: async () => ({})
};
const result = await migration.migrate(ctx);
expect(result.sync.settings).toBeDefined();
});
});
6. Keep Migrations Idempotent
Migrations should be safe to run multiple times:
migrate: async (ctx) => {
const settings = await ctx.getStorage('sync', 'settings');
// ✓ Check if already migrated
if (settings?.version === 2) {
return; // Already migrated, skip
}
return {
sync: {
settings: {
...settings,
version: 2,
newField: 'value'
}
}
};
}
7. Log Migration Progress
import { logger } from '@/shared/lib/logger';
migrate: async (ctx) => {
logger.info('Starting migration 1.2.0...');
const result = await transformData(ctx);
logger.info('Migration 1.2.0 completed');
return result;
}
Advanced Patterns
Conditional Execution
Run migrations based on stored version:
migrate: async (ctx) => {
// Only run if upgrading from v1.x.x
if (ctx.storedVersion?.startsWith('1.')) {
const oldData = await ctx.getStorage('local', 'v1Data');
return {
local: { v2Data: transform(oldData) }
};
}
}
Batch Operations
Update multiple storage areas:
migrate: async (ctx) => {
const [syncData, localData] = await Promise.all([
ctx.getStorage('sync', 'settings'),
ctx.getStorage('local', 'cache')
]);
return {
sync: { settings: transformSync(syncData) },
local: { cache: transformLocal(localData) }
};
}
Multi-Step Migrations
Break complex migrations into smaller steps:
// Migration 1.1.0: Step 1
{
version: '1.1.0',
description: 'Prepare data for new format',
migrate: async (ctx) => {
const data = await ctx.getStorage('sync', 'data');
return {
sync: {
data: { ...data, _migrationStep: 1 }
}
};
}
},
// Migration 1.1.1: Step 2
{
version: '1.1.1',
description: 'Complete migration to new format',
migrate: async (ctx) => {
const data = await ctx.getStorage('sync', 'data');
if (data._migrationStep === 1) {
return {
sync: {
data: finalTransform(data)
}
};
}
}
}
API Reference
runMigrations()
Execute all pending migrations.
Returns: Promise<void>
Throws: Error if migration fails
import { runMigrations } from '@/shared/lib/migration';
await runMigrations();
compareVersions(a, b)
Compare two semantic versions.
Parameters:
a: Version— First versionb: Version— Second version
Returns: -1 | 0 | 1
import { compareVersions } from '@/shared/lib/migration';
compareVersions('1.0.0', '1.1.0'); // -1
compareVersions('2.0.0', '2.0.0'); // 0
compareVersions('1.5.0', '1.2.0'); // 1
parseVersion(version)
Parse version string to number array.
Parameters:
version: Version— Version string
Returns: number[]
import { parseVersion } from '@/shared/lib/migration';
parseVersion('1.2.3'); // [1, 2, 3]
parseVersion('2.0'); // [2, 0, 0]
Troubleshooting
Migration Not Running
- Check extension version in
package.json - Verify
onInstalledlistener is registered - Check browser console for errors
Data Loss
- Always test migrations locally first
- Keep old data during migration
- Only delete after confirming new data is valid
Version Comparison Issues
- Use semantic versioning (x.y.z)
- Ensure version strings are consistent
- Check stored version in sync storage
Testing Migrations
import { describe, it, expect, beforeEach } from 'vitest';
import { runMigrations } from '@/shared/lib/migration';
import { kv } from '@/shared/lib/storage';
describe('Migration System', () => {
beforeEach(async () => {
await kv.clear('sync');
await kv.clear('local');
});
it('should run migrations in order', async () => {
await kv.set('sync', 'version', '1.0.0');
await runMigrations();
const version = await kv.get('sync', 'version');
expect(version).toBe('1.3.0'); // Current version
});
it('should skip already-applied migrations', async () => {
await kv.set('sync', 'version', '1.2.0');
await runMigrations();
// Only 1.3.0 migration should run
});
});
Next Steps
- Learn about Storage for data persistence
- Explore Messaging for communication
- Check Development Guide for workflow