Migration API
Data migration and versioning utilities for evolving your storage schema.
Note: Migrations are an EXPERIMENTAL, adapter-level utility (
MigrationManager). They are not configured throughStrataConfig— there is nomigrationsoption onnew Strata(...). You run them explicitly against a low-level storage adapter (e.g.LocalStorageAdapter,IndexedDBAdapter).
Overview
As your application evolves, you may need to change the structure of stored data. The Migration API provides a small, standalone tool — MigrationManager — to safely transform data from old formats to new formats. It tracks the current schema version internally (under the key __strata_version__) and runs the registered migrations needed to move up or down to a target version.
Importing
Everything ships from the main package entry point:
import { MigrationManager, LocalStorageAdapter } from 'strata-storage';
import type { Migration } from 'strata-storage';
The Migration Interface
interface Migration {
version: number;
up: (adapter: StorageAdapter) => Promise<void>;
down?: (adapter: StorageAdapter) => Promise<void>;
}
version— the schema version this migration produces. Migrations are run in ascending order when migrating up, and in descending order when migrating down.up— applies the migration. Receives the low-levelStorageAdapter.down— optional reverse migration, used when migrating to a lower target version.
The up/down callbacks receive a StorageAdapter, so they read and write through the adapter's primitives: adapter.get(key), adapter.set(key, value), adapter.remove(key), adapter.keys(), adapter.has(key), and adapter.clear(). Note that adapter.get(key) returns a StorageValue wrapper ({ value, created, updated, ... } or null), and adapter.set(key, value) expects that same wrapper shape.
The MigrationManager Class
MigrationManager has exactly two public methods:
| Method | Description |
|---|---|
register(migration: Migration): void | Register a migration. Migrations are kept sorted by version. |
migrate(adapter: StorageAdapter, targetVersion: number): Promise<void> | Migrate the adapter's data up or down to targetVersion. |
const manager = new MigrationManager();
manager.register(migration);
await manager.migrate(adapter, targetVersion);
migrate() reads the current version from the adapter, compares it to targetVersion, and:
- runs each pending migration's
up()in ascending order when moving up, or - runs each migration's
down()in descending order when moving down.
After each step it persists the new version under __strata_version__. If the current version already equals targetVersion, migrate() is a no-op.
Setting Up an Adapter
migrate() operates on an initialized adapter, not a Strata instance:
import { LocalStorageAdapter } from 'strata-storage';
const adapter = new LocalStorageAdapter();
await adapter.initialize();
Any adapter that implements StorageAdapter works — for example IndexedDBAdapter, MemoryAdapter, or a Capacitor adapter.
Defining and Running Migrations
Basic Migration
import { MigrationManager, LocalStorageAdapter } from 'strata-storage';
import type { Migration } from 'strata-storage';
const adapter = new LocalStorageAdapter();
await adapter.initialize();
const manager = new MigrationManager();
manager.register({
version: 1,
up: async (a) => {
// Add a default theme if none exists
const theme = await a.get('theme');
if (!theme) {
await a.set('theme', { value: 'light', created: Date.now(), updated: Date.now() });
}
},
});
manager.register({
version: 2,
up: async (a) => {
// Rename 'username' to a structured 'user' record
const username = await a.get<string>('username');
if (username) {
await a.set('user', {
value: { name: username.value },
created: Date.now(),
updated: Date.now(),
});
await a.remove('username');
}
},
});
// Migrate up to version 2 (runs v1 then v2 as needed)
await manager.migrate(adapter, 2);
Migration with Rollback
Provide a down function so the migration can be reversed when you migrate to a lower target version:
const migration: Migration = {
version: 2,
up: async (a) => {
const username = await a.get<string>('username');
if (username) {
await a.set('user', {
value: { name: username.value },
created: Date.now(),
updated: Date.now(),
});
await a.remove('username');
}
},
down: async (a) => {
const user = await a.get<{ name: string }>('user');
if (user?.value?.name) {
await a.set('username', {
value: user.value.name,
created: Date.now(),
updated: Date.now(),
});
await a.remove('user');
}
},
};
manager.register(migration);
// Roll back to version 1 (runs v2's `down`)
await manager.migrate(adapter, 1);
Real-World Examples
User Profile Migration
import { MigrationManager, IndexedDBAdapter } from 'strata-storage';
const adapter = new IndexedDBAdapter();
await adapter.initialize();
const manager = new MigrationManager();
// Version 2: migrate flat username/email into a single user object
manager.register({
version: 2,
up: async (a) => {
const username = await a.get<string>('username');
const email = await a.get<string>('email');
if (username || email) {
await a.set('user', {
value: {
username: username?.value ?? '',
email: email?.value ?? '',
createdAt: Date.now(),
},
created: Date.now(),
updated: Date.now(),
});
await a.remove('username');
await a.remove('email');
}
},
down: async (a) => {
const user = await a.get<{ username: string; email: string }>('user');
if (user) {
const now = Date.now();
await a.set('username', { value: user.value.username, created: now, updated: now });
await a.set('email', { value: user.value.email, created: now, updated: now });
await a.remove('user');
}
},
});
// Version 3: add user preferences
manager.register({
version: 3,
up: async (a) => {
const user = await a.get<Record<string, unknown>>('user');
if (user && !user.value.preferences) {
await a.set('user', {
value: {
...user.value,
preferences: { theme: 'light', language: 'en', notifications: true },
},
created: user.created,
updated: Date.now(),
});
}
},
down: async (a) => {
const user = await a.get<Record<string, unknown>>('user');
if (user?.value?.preferences) {
const { preferences, ...rest } = user.value as Record<string, unknown>;
await a.set('user', { value: rest, created: user.created, updated: Date.now() });
}
},
});
await manager.migrate(adapter, 3);
Settings Structure Migration
// Migrate from flat settings to a nested structure
manager.register({
version: 2,
up: async (a) => {
const theme = await a.get<string>('setting_theme');
const lang = await a.get<string>('setting_language');
const notifications = await a.get<boolean>('setting_notifications');
await a.set('settings', {
value: {
appearance: { theme: theme?.value ?? 'light' },
locale: { language: lang?.value ?? 'en' },
notifications: { enabled: notifications?.value ?? true },
},
created: Date.now(),
updated: Date.now(),
});
await a.remove('setting_theme');
await a.remove('setting_language');
await a.remove('setting_notifications');
},
down: async (a) => {
const settings = await a.get<{
appearance?: { theme?: string };
locale?: { language?: string };
notifications?: { enabled?: boolean };
}>('settings');
if (settings) {
const now = Date.now();
await a.set('setting_theme', { value: settings.value.appearance?.theme, created: now, updated: now });
await a.set('setting_language', { value: settings.value.locale?.language, created: now, updated: now });
await a.set('setting_notifications', { value: settings.value.notifications?.enabled, created: now, updated: now });
await a.remove('settings');
}
},
});
Data Type Migration
// Convert date strings to timestamps across all task records
manager.register({
version: 2,
up: async (a) => {
const keys = await a.keys();
for (const key of keys) {
if (!key.startsWith('task:')) continue;
const record = await a.get<{ dueDate?: unknown; createdAt?: unknown }>(key);
if (!record) continue;
const task = { ...record.value };
let changed = false;
if (typeof task.dueDate === 'string') {
task.dueDate = new Date(task.dueDate).getTime();
changed = true;
}
if (typeof task.createdAt === 'string') {
task.createdAt = new Date(task.createdAt).getTime();
changed = true;
}
if (changed) {
await a.set(key, { value: task, created: record.created, updated: Date.now() });
}
}
},
});
Namespace Migration
// Add namespacing to keys
manager.register({
version: 2,
up: async (a) => {
const keyMapping: Record<string, string> = {
theme: 'app:theme',
language: 'app:language',
token: 'auth:token',
user: 'auth:user',
};
for (const [oldKey, newKey] of Object.entries(keyMapping)) {
const record = await a.get(oldKey);
if (record !== null) {
await a.set(newKey, record);
await a.remove(oldKey);
}
}
},
down: async (a) => {
const keyMapping: Record<string, string> = {
'app:theme': 'theme',
'app:language': 'language',
'auth:token': 'token',
'auth:user': 'user',
};
for (const [newKey, oldKey] of Object.entries(keyMapping)) {
const record = await a.get(newKey);
if (record !== null) {
await a.set(oldKey, record);
await a.remove(newKey);
}
}
},
});
Best Practices
- Version Sequentially: Start at 1, increment by 1.
- Initialize the adapter first: Call
await adapter.initialize()beforemigrate(). - Provide Rollback: Implement
downwhenever a migration is reversible. - Preserve the wrapper: Remember
get()returns{ value, created, updated }andset()expects the same shape — re-wrap your transformed value. - Back Up Data: Snapshot critical data before running migrations (see
snapshot()/restore()in the disaster-recovery features). - Keep migrations small: One focused change per version is easier to test and reverse.
- Migrations are experimental: Test thoroughly against real adapter data before relying on them in production.