Migration
Move existing data into ultrastorage or migrate safely to another storage solution.
Storage migrations should operate on decoded values rather than copying raw backend strings.
ultrastorage wraps values in a versioned envelope and, by default, serializes that envelope with
devalue. The examples below preserve data by reading it through the library that wrote it.
The default serializer uses devalue 6. Supported entries written with devalue 5 remain readable, including rich values, shared and circular references, and expiration metadata. Keep the same backend, prefix, and separator when upgrading; no stored-data conversion is required for this serializer update.
Upgrading from automatic JSON fallback
Earlier versions tried JSON.parse() automatically when the configured parser threw. Fallback
is now opt-in, including when using the default devalue serializer. To preserve the previous
behavior, add fallbackParser: JSON.parse to each instance that needs to read JSON envelopes:
import { createStorage } from 'ultrastorage';
export const appStorage = createStorage({
prefix: 'app',
fallbackParser: JSON.parse,
});
The same option works with custom serializers and ultrastorage/core. Keep any existing
serializer, prefix, separator, and backend configuration when adding it.
Without fallback, entries the primary parser rejects remain in storage. getItem(key, { detailed: true }) returns
parse-error, getItem() returns null, and enumeration, clear(), and clearExpired() skip them.
React hooks and reactive expiration use the same parsing policy. Read-modify-write helpers can
replace an unreadable entry, so configure compatibility before using them on old data.
Fallback reads do not rewrite the format. To migrate an entry, read it successfully through a
compatible instance, then write its value with the new serializer while preserving any expiration
you need. Keep compatibility enabled while older clients can still write the old format. To remove
a known obsolete entry without decoding it, use removeItem(key).
Fallback parsers must return a recognized ultrastorage envelope. For ordinary JSON values or raw strings written outside ultrastorage, use legacy imports.
Migrating to ultrastorage
Importing existing values
Pass migrateFrom to getItem() to adopt an entry previously written to the same backend.
The destination uses your instance’s namespace; migrateFrom.key is the exact source key,
without an added prefix. Use a new, non-overlapping prefix when changing formats.
import { createStorage } from 'ultrastorage';
const appStorage = createStorage({ prefix: 'app-ultra-v1' });
const theme = appStorage.getItem<string>('theme', {
migrateFrom: {
key: 'old:theme',
deserialize: (raw) => ({ value: raw }),
},
});
deserialize must synchronously return an object with value and optional options.
The options reuse StorageOptions: ttl starts a lifetime at import time, expiresAt sets
an absolute timestamp or Date, and expiresAt: null means no expiration. Never specify both
ttl and expiresAt. Omitted options import without expiration.
Keep format decoding in your app. For example, if an old JSON record contains { value, expiry }:
const promo = appStorage.getItem('promo', {
migrateFrom: {
key: 'old:promo',
deserialize(raw) {
const old = JSON.parse(raw);
return {
value: old.value,
options: { expiresAt: old.expiry ?? null },
};
},
},
});
Use the original absolute deadline to preserve remaining lifetime. Migration never adds back elapsed time. Already-expired data is skipped, with the original source retained. A deadline is still valid at the exact timestamp, matching ordinary reads.
Add a synchronous Standard Schema to validate the decoded value and infer its output type:
import { z } from 'zod';
const preferences = appStorage.getItem('preferences', {
migrateFrom: {
key: 'old:settings',
deserialize: (raw) => ({ value: JSON.parse(raw) }),
},
schema: z.object({ theme: z.enum(['dark', 'light']) }),
});
Confirm an import
Use detailed mode on the same read when the app needs confirmation before verification and cleanup:
const result = appStorage.getItem('draft', {
detailed: true,
migrateFrom: {
key: 'old:draft',
deserialize: (raw) => ({ value: JSON.parse(raw) }),
},
});
if (result.status === 'success' && result.migrated) {
// Destination bytes and expiration were verified. The source is still retained.
// Verify app-specific data and check for newer source writes before app-owned cleanup.
}
All detailed outcomes include __result: true. A successful read of an occupied
destination omits migrated. Decoder failures and schema issues have distinct outcomes;
contract, storage, and write errors still throw. See detailed reads.
Breaking change from legacy
The legacy option was renamed to migrateFrom; old calls now throw a descriptive error.
Rename the exported LegacyOptions type to MigrateFromOptions too. All deserializers must
return the wrapper, including strings and JSON objects:
// Before:
// legacy: { key: 'old', deserialize: JSON.parse }
// After:
// migrateFrom: { key: 'old', deserialize: raw => ({ value: JSON.parse(raw) }) }
The source is now retained after success instead of automatically deleted. After verifying the
new data, the app can remove the exact source key with the backend’s removeItem(). Retained
sources can be imported again if the destination is deleted. Plan app-owned cleanup or migration
completion tracking, and coordinate older clients that can still write the source format.
Import behavior
- Importing runs only when the destination is physically missing. Existing values win,
including stored
null, malformed, foreign, expired, or schema-invalid entries. An expired destination is removed by the ordinary read path; a later read can attempt importing. - A missing source returns
null. Empty source strings reach the deserializer. Source and destination at the same physical key cannot trigger an import. - The validated, transformed output is serialized with the supplied expiration. Reads of an existing destination do not call the deserializer or refresh its deadline.
- Source bytes and destination presence are checked again after user callbacks. A newer destination wins; a changed source throws so the app can retry instead of copying stale data.
- After writing, the destination is read back to verify its bytes, recognized envelope, and expiration. Failed verification throws and can leave destination bytes behind for inspection.
- Decoder exceptions and schema issues return
null, retaining the source. Malformed wrappers, invalid options, and asynchronous callbacks throw aTypeError. Thrown schema, serialization, and backend errors propagate. Every import failure retains the source. - With quota recovery enabled, expired entries in the destination namespace may be cleared and the write retried once. The source key is excluded from this cleanup.
- Successful changed writes emit the ordinary
setnotification after verification. Source retention emits no removal notification.
Importing is synchronous and is not atomic across tabs. Run importing reads in browser startup,
an effect, or an event handler, never React rendering. Hooks do not import by themselves, but
mounted subscribers update when a getItem() call imports a value.
From greatstorage
ultrastorage was previously published as greatstorage. To upgrade:
- Replace the
greatstoragedependency withultrastorage. - Change imports from
greatstoragetoultrastorage, includinggreatstorage/coretoultrastorage/core. - Rename type imports from
GreatStoragetoUltraStorage.
Existing entries marked with __gs remain readable, so stored browser data does not need to be
migrated. New writes use __us, which older versions of greatstorage cannot read. Update
applications that share the same storage together rather than running old and new versions at the
same time.
Migrating away from ultrastorage
Use the public API to enumerate and decode current entries. Do not copy raw localStorage or
sessionStorage strings: they contain ultrastorage metadata and may use a serializer that the new
solution cannot decode.
For every key returned by keys():
- Call
getItem(key, { detailed: true })to distinguish a successfully storednullfrom a failed read. - Read
getExpiration()if the destination supports expiration. - Convert array keys if the destination accepts only string keys.
- Write the decoded value to the destination and verify that it can be read back.
- Remove the ultrastorage entry only after the destination write is known to be durable.
The destination API will differ, but the migration loop should follow this shape:
import type { StorageKey } from 'ultrastorage';
import { appStorage } from './lib/app-storage';
type Destination = {
set(key: StorageKey, value: unknown, options: { expiresAt: number | null }): void | Promise<void>;
};
export async function migrateStorage(destination: Destination) {
for (const key of appStorage.keys()) {
const result = appStorage.getItem(key, { detailed: true });
if (result.status !== 'success') continue;
const expiration = appStorage.getExpiration(key);
await destination.set(key, result.value, {
expiresAt: expiration?.expiresAt ?? null,
});
}
}
keys() returns a snapshot of recognized, non-expired entries. Expired, corrupted, unsupported,
and foreign backend values are skipped. If those bytes also need cleanup, inspect and remove their
exact backend keys separately; clear() intentionally leaves unreadable and foreign values alone.
Check that the destination’s serializer supports the values your application uses. Plain JSON,
for example, does not faithfully preserve Date, Map, Set, BigInt, undefined, or circular
references. Also define a reversible encoding for array keys before writing to a destination that
only accepts strings.
Roll out without losing writes
For applications that may have multiple releases or browser tabs running at once, migrate in stages:
- Ship a release that reads the new destination first, falls back to ultrastorage, and writes to both.
- Copy and verify existing values through the migration loop.
- Stop reading from ultrastorage after old clients are no longer active.
- Remove the old entries and uninstall the package in a later release.
This prevents an older client from writing fresh ultrastorage data after the one-time copy has finished. If the stored data is disposable, such as a cache, you can instead clear the namespace and let the new implementation rebuild it.