@sebastianthiebaud/schema-versioned-storage
v0.2.3
Published
Type-safe, versioned persisted state with automatic migrations
Maintainers
Readme
@sebastianthiebaud/schema-versioned-storage
Type-safe, versioned persisted state with automatic migrations for TypeScript applications.
Features
- 🔒 Type-safe: Full TypeScript support with Zod schema validation
- 🔄 Automatic migrations: Seamlessly migrate between schema versions
- 💾 Storage agnostic: Works with AsyncStorage (React Native), localStorage (Web), or custom adapters
- 🎯 Minimal dependencies: Only requires Zod (automatically installed)
- 🧪 Test-friendly: Includes memory adapter for testing
- 📦 Tree-shakeable: ES modules support
Why This Package?
Type-Safe State Management
Get full TypeScript autocomplete and type checking for your persisted state:
// Define your schema once
const schema = z.object({
preferences: z.object({
colorScheme: z.enum(['light', 'dark', 'system']),
language: z.string(),
}),
});
// TypeScript knows exactly what you can get/set
const storage = createPersistedState({ schema, /* ... */ });
// ✅ Type-safe getter - autocomplete works!
const colorScheme = storage.get('preferences').colorScheme;
// ✅ Property-based access using getAll()
const { preferences } = storage.getAll();
const colorScheme2 = preferences.colorScheme;
// Or: storage.getAll().preferences.colorScheme
// ✅ Type-safe setter - TypeScript catches errors
await storage.set('preferences', { colorScheme: 'dark' }); // ✅
await storage.set('preferences', { colorScheme: 'invalid' }); // ❌ TypeScript error
// ❌ TypeScript error - 'invalidKey' doesn't exist
const invalid = storage.get('invalidKey');Automatic Migrations
Add new fields or change your schema without breaking existing users:
// Migration file: src/migrations/2-add-language.ts
import type { Migration } from '@sebastianthiebaud/schema-versioned-storage';
const migration: Migration<PersistedSchema> = {
metadata: { version: 2, description: 'Add language preference' },
migrate: (oldState) => ({
...oldState,
_version: 2,
preferences: {
...oldState.preferences,
language: 'en', // Default for existing users
},
}),
};
export default migration;The migration runs automatically when users update your app! 🎉
👉 See full Quick Start guide | Learn about migrations | View API reference
Installation
npm install @sebastianthiebaud/schema-versioned-storageZod will be automatically installed as a dependency.
For React Native, also install:
npm install @react-native-async-storage/async-storageQuick Start
1. Define your schema with inline defaults
// src/schema.ts
import { z } from 'zod';
export const persistedSchema = z.object({
/**
* Schema version for migration support
* The default value is set at runtime in persisted.ts
*/
_version: z.number(),
preferences: z
.object({
colorScheme: z.enum(['system', 'light', 'dark']).default('system'),
})
.default({ colorScheme: 'system' }),
});
export type PersistedSchema = z.infer<typeof persistedSchema>;2. Create migrations (optional)
// src/migrations/2-add-feature.ts
import type { Migration } from '@sebastianthiebaud/schema-versioned-storage';
import type { PersistedSchema } from '../schema';
const migration: Migration<PersistedSchema> = {
metadata: {
version: 2,
description: 'Add new preferences field',
},
migrate: (state: unknown): PersistedSchema => {
const oldState = state as any;
return {
...oldState,
_version: 2,
preferences: {
...oldState.preferences,
colorScheme: oldState.preferences?.colorScheme || 'system',
},
};
},
};
export default migration;3. Configure paths (optional)
Add configuration to your package.json to avoid passing paths every time:
{
"schemaVersionedStorage": {
"schema": {
"file": "./src/schema.ts",
"hashesOutput": "./src/schema-hashes.ts"
},
"migrations": {
"dir": "./src/migrations",
"indexPath": "./src/migrations/index.ts",
"typesPath": "@sebastianthiebaud/schema-versioned-storage"
}
},
"scripts": {
"generate:migrations": "svs generate:migrations",
"generate:schema-hashes": "svs generate:schema-hashes",
"generate:all": "svs generate:all"
}
}Note: If you don't configure paths, the scripts will use defaults (./src/...). See the Configuration section for all options.
4. Generate migrations index and schema hashes
Run the generation scripts using the CLI tool:
# Using the CLI (recommended)
npx svs generate:migrations
npx svs generate:schema-hashes
# Or generate both at once
npx svs generate:allOr add npm scripts to your package.json:
{
"scripts": {
"generate:migrations": "svs generate:migrations",
"generate:schema-hashes": "svs generate:schema-hashes",
"generate:all": "svs generate:all"
}
}Then run:
npm run generate:migrations
npm run generate:schema-hashesNote: The CLI tool (svs) is automatically installed with the package. You can use it directly with npx svs or add it to your npm scripts.
5. Initialize storage
// src/storage.ts
import { createPersistedState } from '@sebastianthiebaud/schema-versioned-storage';
import { createAsyncStorageAdapter } from '@sebastianthiebaud/schema-versioned-storage/adapters/async-storage';
import { persistedSchema } from './schema';
import { getMigrations, getCurrentSchemaVersion } from './migrations';
import { SCHEMA_HASHES_BY_VERSION } from './schema-hashes';
export const storage = createPersistedState({
schema: persistedSchema,
storageKey: 'MY_APP_STATE',
storage: createAsyncStorageAdapter(),
migrations: Array.from(getMigrations().values()),
getCurrentVersion: getCurrentSchemaVersion,
schemaHashes: SCHEMA_HASHES_BY_VERSION,
});
// Initialize in your app
await storage.init();6. Use the storage
// Get values - method-based access
const colorScheme = storage.get('preferences').colorScheme;
// Get values - property-based access using getAll()
const { preferences } = storage.getAll();
const colorScheme2 = preferences.colorScheme;
// Or access directly: storage.getAll().preferences.colorScheme
// Set values
await storage.set('preferences', {
colorScheme: 'dark',
});
// Update values
await storage.update('preferences', (prev) => ({
...prev,
colorScheme: 'light',
}));
// Get all state
const allState = storage.getAll();7b. Using React Hooks (Optional)
For React applications, you can use the StorageProvider and useStorage hook to access storage from any component without prop drilling:
// src/App.tsx
import { StorageProvider, useStorage } from '@sebastianthiebaud/schema-versioned-storage/react';
import { storage } from './storage'; // Your storage instance
import { useEffect, useState } from 'react';
function App() {
const [initialized, setInitialized] = useState(false);
useEffect(() => {
storage.init().then(() => setInitialized(true));
}, []);
if (!initialized) return <div>Loading...</div>;
return (
<StorageProvider storage={storage} initialized={initialized}>
<MyComponent />
</StorageProvider>
);
}
// Any component can now access storage
function MyComponent() {
const storage = useStorage<PersistedSchema>();
const colorScheme = storage.get('preferences').colorScheme;
const handleChange = async () => {
await storage.set('preferences', {
...storage.get('preferences'),
colorScheme: 'dark',
});
};
return <button onClick={handleChange}>Toggle Theme</button>;
}Note: React hooks are optional. If you're not using React, you can use the storage instance directly as shown in step 7.
API Reference
createPersistedState<TSchema>(config)
Creates a persisted state instance.
Parameters:
schema: Zod schema for your state (with inline defaults using.default())storageKey: Key to use in storagestorage: Storage adapter instancemigrations: Array of migrations (optional)getCurrentVersion: Function that returns the current schema versionschemaHashes: Record mapping version numbers to schema hashes
Note: Default values are now defined inline in your Zod schema using .default(). The _version field is automatically set at runtime.
Returns: PersistedState<TSchema>
PersistedState<TSchema>
init(): Promise<void>
Initialize the storage. Must be called before using other methods.
get<K>(key: K): TSchema[K]
Get a value from the state using method-based access.
Example:
const colorScheme = storage.get('preferences').colorScheme;getAll(): TSchema
Get all state as an object. Useful for property-based access or when you need multiple values.
Example:
// Property-based access
const { preferences } = storage.getAll();
const colorScheme = preferences.colorScheme;
// Or access directly
const colorScheme = storage.getAll().preferences.colorScheme;
// Get all state for destructuring
const allState = storage.getAll();set<K>(key: K, value: TSchema[K]): Promise<void>
Set a value in the state.
update<K>(key: K, updater: (prev: TSchema[K]) => TSchema[K]): Promise<void>
Update a value using an updater function.
getAll(): TSchema
Get all state.
clear(): Promise<void>
Clear all state and reset to defaults.
getSchemaVersion(): number
Get the current schema version.
getSchemaHash(): string
Get the schema hash for the current version.
getSchemaHashForVersion(version: number): string | undefined
Get the schema hash for a specific version.
React Hooks (Optional)
StorageProvider<TSchema>(props)
Provider component that makes the storage instance available to all child components via React Context.
Props:
storage:PersistedState<TSchema>- The storage instanceinitialized:boolean(optional) - Whether storage has been initializedchildren:ReactNode- Child components
Example:
<StorageProvider storage={storage} initialized={true}>
<App />
</StorageProvider>useStorage<TSchema>(): PersistedState<TSchema>
Hook to access the storage instance from any component within a StorageProvider.
Returns: The storage instance
Throws: Error if used outside of StorageProvider
Example:
function MyComponent() {
const storage = useStorage<PersistedSchema>();
const value = storage.get('preferences');
// ...
}useStorageInitialized(): boolean
Hook to check if storage has been initialized.
Returns: true if storage is initialized, false otherwise
Storage Adapters
AsyncStorage (React Native)
import { createAsyncStorageAdapter } from '@sebastianthiebaud/schema-versioned-storage/adapters/async-storage';
const storage = createPersistedState({
// ... other config
storage: createAsyncStorageAdapter(),
});localStorage (Web)
import { createLocalStorageAdapter } from '@sebastianthiebaud/schema-versioned-storage/adapters/local-storage';
const storage = createPersistedState({
// ... other config
storage: createLocalStorageAdapter(),
});Memory (Testing)
import { createMemoryAdapter } from '@sebastianthiebaud/schema-versioned-storage/adapters/memory';
const storage = createPersistedState({
// ... other config
storage: createMemoryAdapter(),
});Custom Adapter
import type { StorageAdapter } from '@sebastianthiebaud/schema-versioned-storage';
const customAdapter: StorageAdapter = {
async getItem(key: string): Promise<string | null> {
// Your implementation
},
async setItem(key: string, value: string): Promise<void> {
// Your implementation
},
async removeItem(key: string): Promise<void> {
// Your implementation
},
};Migration System
Migrations are automatically run when the stored version is older than the current version. Migrations must be:
- Named with the pattern:
{version}-{description}.ts(e.g.,2-add-feature.ts) - Export a default
Migration<TSchema>object - Have sequential version numbers
Example migration:
import type { Migration } from '@sebastianthiebaud/schema-versioned-storage';
import type { PersistedSchema } from '../schema';
const migration: Migration<PersistedSchema> = {
metadata: {
version: 2,
description: 'Add new field',
},
migrate: (state: unknown): PersistedSchema => {
const oldState = state as any;
return {
...oldState,
_version: 2,
// Add your migration logic here
};
},
};
export default migration;Configuration
You can configure the paths for your schema and migrations in several ways. Configuration priority (highest to lowest):
- CLI Arguments (highest priority)
- Config File (via
--config) - package.json (if
schemaVersionedStoragefield exists) - Default paths (lowest priority)
Option 1: Using package.json (Recommended)
Add a schemaVersionedStorage field to your package.json:
Nested format (recommended):
{
"name": "my-app",
"version": "1.0.0",
"schemaVersionedStorage": {
"schema": {
"file": "./lib/storage/schema.ts",
"hashesOutput": "./lib/storage/schema-hashes.ts"
},
"migrations": {
"dir": "./lib/storage/migrations",
"indexPath": "./lib/storage/migrations/index.ts",
"typesPath": "my-app"
}
}
}Flat format (also supported):
{
"name": "my-app",
"version": "1.0.0",
"schemaVersionedStorage": {
"schemaFile": "./lib/storage/schema.ts",
"outputPath": "./lib/storage/schema-hashes.ts",
"migrationsDir": "./lib/storage/migrations",
"indexPath": "./lib/storage/migrations/index.ts",
"typesPath": "my-app"
}
}Then run the scripts using the CLI:
# Using npx (recommended)
npx svs generate:migrations
npx svs generate:schema-hashes
# Or generate both at once
npx svs generate:allOr add npm scripts to your package.json:
{
"scripts": {
"generate:migrations": "svs generate:migrations",
"generate:schema-hashes": "svs generate:schema-hashes",
"generate:all": "svs generate:all"
}
}Then simply run:
npm run generate:migrations
npm run generate:schema-hashesOption 2: Using CLI Arguments
Pass paths directly as command-line arguments:
# Generate migrations index
npx svs generate:migrations \
--migrations-dir ./lib/storage/migrations \
--index-path ./lib/storage/migrations/index.ts \
--types-path my-app
# Generate schema hashes
npx svs generate:schema-hashes \
--schema-file ./lib/storage/schema.ts \
--output-path ./lib/storage/schema-hashes.tsOption 3: Using a Config File
Create a schema-versioned-storage.config.json file in your project root. You can use either a nested format (recommended) or a flat format:
Nested format (recommended):
{
"schema": {
"file": "./lib/storage/schema.ts",
"hashesOutput": "./lib/storage/schema-hashes.ts"
},
"migrations": {
"dir": "./lib/storage/migrations",
"indexPath": "./lib/storage/migrations/index.ts",
"typesPath": "my-app"
}
}Flat format (also supported):
{
"schemaFile": "./lib/storage/schema.ts",
"outputPath": "./lib/storage/schema-hashes.ts",
"migrationsDir": "./lib/storage/migrations",
"indexPath": "./lib/storage/migrations/index.ts",
"typesPath": "my-app"
}Then run the scripts with the config file:
npx svs generate:migrations --config schema-versioned-storage.config.json
npx svs generate:schema-hashes --config schema-versioned-storage.config.jsonNote: CLI arguments will override values from package.json or config files.
Default Paths
If no configuration is provided, the scripts use these defaults:
- Schema file:
./src/schema.ts - Schema hashes output:
./src/schema-hashes.ts - Migrations directory:
./src/migrations - Migrations index:
./src/migrations/index.ts - Types path: Automatically uses your package name from
package.json(orschema-versioned-storageas fallback)
Scripts
Generate Migrations Index
Generates an index file that exports all migrations. The script scans the migrations directory for files matching the pattern {version}-{description}.ts and creates an index file.
CLI Options:
--migrations-dir <path>- Directory containing migration files (default:./src/migrations)--index-path <path>- Output path for the index file (default:./src/migrations/index.ts)--types-path <path>- Path to import Migration types from (default: your package name frompackage.json)--config <file>- Path to a JSON config file
Example:
npx svs generate:migrations \
--migrations-dir ./lib/storage/migrations \
--index-path ./lib/storage/migrations/index.ts \
--types-path my-appGenerate Schema Hashes
Generates a file with schema hashes for each version. These hashes are used to detect schema changes.
CLI Options:
--schema-file <path>- Path to your schema file (default:./src/schema.ts)--output-path <path>- Output path for schema hashes file (default:./src/schema-hashes.ts)--config <file>- Path to a JSON config file
Example:
npx svs generate:schema-hashes \
--schema-file ./lib/storage/schema.ts \
--output-path ./lib/storage/schema-hashes.tsExamples
See the examples/ directory for complete examples:
basic-usage.ts- Basic usage with memory adapterreact-native-usage.ts- React Native usage with AsyncStoragereact-hook-usage.tsx- React hooks with StorageProvider and useStorage
Testing
The package includes a memory adapter perfect for testing:
import { createMemoryAdapter } from '@sebastianthiebaud/schema-versioned-storage/adapters/memory';
const storage = createPersistedState({
// ... config
storage: createMemoryAdapter(),
});Type Safety
The package provides full type safety:
// TypeScript knows the exact shape of your state
const colorScheme = storage.get('preferences').colorScheme; // ✅
const invalid = storage.get('nonexistent'); // ❌ TypeScript error
// Set operations are type-checked
await storage.set('preferences', { colorScheme: 'dark' }); // ✅
await storage.set('preferences', { invalid: 'value' }); // ❌ TypeScript errorReleasing
This package uses GitHub Actions to automatically publish to both npmjs.org and GitHub Packages when a new release is created on GitHub.
Release Process
- Update the version in
package.jsonfollowing Semantic Versioning - Update
CHANGELOG.mdwith the changes - Commit and push your changes
- Create a new release on GitHub:
- Go to the repository's Releases page
- Click "Create a new release"
- Choose a tag (e.g.,
v1.0.0) - Important: The tag version must match the version inpackage.json(without thevprefix) - Fill in the release title and description
- Click "Publish release"
The GitHub Action will automatically:
- Verify the version in
package.jsonmatches the release tag - Run tests
- Build the package
- Publish to npmjs.org
- Publish to GitHub Packages
License
MIT
Contributing
Contributions are welcome! Please open an issue or submit a pull request.
