npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

schema-idb

v0.0.9

Published

Type-safe IndexedDB wrapper with chainable transactions

Readme

schema-idb

A type-safe IndexedDB layer that brings structure to client-side storage.

Live Example

const db = openDB({
  name: "MyApp",
  versionStrategy: "auto",
  stores: [usersStore] as const,
});

await db.users.put({ id: "u1", name: "Kim", email: "[email protected]" });
const user = await db.users.get("u1");

What schema-idb provides

IndexedDB is a capable but low-level API. schema-idb adds a thin structure layer:

  • End-to-end type safety — Schema, queries, and results are all inferred from a single definition.
  • Schema-first design — Define your data model explicitly. The database follows your schema.
  • Write-time defaults — Default values (and factory functions) are injected when records are written, so they persist in the database.
  • Predictable queries — Type-safe, index-backed queries that map directly to IndexedDB capabilities.
  • Opt-in runtime validation — Validate records on read against your schema via a resolver (e.g. Zod), to catch data left invalid by a missed migration.
  • Zero core dependencies — The core ships with no dependencies. Validation resolvers are separate entry points; their validator (e.g. Zod) is an optional peer dependency you only pay for if you use it.

Installation

npm install schema-idb

Quick Start

1. Define a store

import { defineStore, field } from "schema-idb";

const usersStore = defineStore("users", {
  id: field.string().primaryKey(),
  name: field.string().index(),
  email: field.string().index({ unique: true }),
  age: field.number().optional().default(0),
  createdAt: field.date().default(() => new Date()),
});

2. Open the database

import { openDB } from "schema-idb";

const db = openDB({
  name: "MyApp",
  versionStrategy: "auto",
  stores: [usersStore] as const,
});

3. Use it

// Create or update
await db.users.put({ id: "u1", name: "Kim", email: "[email protected]" });

// Read
const user = await db.users.get("u1");
console.log(user?.age); // 0 (default injected at write time)

// Query
const adults = await db.users.query({
  index: "age",
  where: { gte: 18 },
  limit: 10,
});

// Delete
await db.users.delete("u1");

Schema Definition

Field types

field.string(); // string
field.number(); // number
field.boolean(); // boolean
field.date(); // Date
field.string().array(); // string[]
field.object({
  // nested object — fields composed with the same `field` builder
  street: field.string(),
  city: field.string(),
});
field.tuple([field.number(), field.number()]); // [number, number]
field.enum(["active", "inactive"] as const); // union type

Field modifiers

field
  .string()
  .primaryKey() // set as primary key (exactly one per store)
  .index() // create index for querying
  .index({ unique: true }) // unique index
  .optional() // allow undefined
  .default(value) // default value or factory function
  .array(); // convert to array type

Indexes

Only indexed fields can be queried. Non-indexed fields are stored but not searchable.

const usersStore = defineStore("users", {
  id: field.string().primaryKey(),
  email: field.string().index(), // ✓ searchable via query()
  bio: field.string(), // ✗ stored, but not searchable
});

// Works
await db.users.query({ index: "email", where: { eq: "[email protected]" } });

// Error: 'bio' is not an index
await db.users.query({ index: "bio", where: { eq: "..." } });

Complete example

const usersStore = defineStore("users", {
  id: field.string().primaryKey(),
  email: field.string().index({ unique: true }),
  name: field.string().index(),
  age: field.number().optional().default(0).index(),
  role: field.enum(["admin", "user"] as const).default("user"),
  tags: field.string().array().optional(),
  profile: field
    .object({
      bio: field.string().optional(),
      avatar: field.string().optional(),
    })
    .optional(),
  createdAt: field
    .date()
    .index()
    .default(() => new Date()),
});

Runtime Validation

TypeScript checks your code, but it can't check data that already lives in the database — especially records left behind by a missed migration after a schema change. Runtime validation closes that gap by validating records on read against your current schema.

Validation is opt-in and lives in a separate entry point, so the core stays dependency-free. The resolvers/zod resolver builds a Zod schema automatically from your field definitions — you define your schema once.

import { defineStore, field, openDB } from "schema-idb";
import { zodResolver } from "schema-idb/resolvers/zod";

const usersStore = defineStore("users", {
  id: field.string().primaryKey(),
  name: field.string(),
  age: field.number(),
}).use(zodResolver()); // validate records on read

const db = openDB({
  name: "MyApp",
  versionStrategy: "auto",
  stores: [usersStore] as const,
});

// A record missing `age` (e.g. written before the field existed, or via raw)
// throws when read, surfacing the migration gap instead of silently returning bad data.
await db.users.get("legacy-id"); // throws if the record fails validation

zod is an optional peer dependency. Install it only if you use the resolver:

npm install zod

How it behaves

  • Validation runs on get, getAll, getBy, getAllBy, and query. Writes are not validated (the input is already typed).
  • A field is validated as required unless it is optional(). A default() does not make a field optional for validation — since defaults are injected on write, a missing default field signals a migration gap and fails validation.
  • If you don't call .use(...), there is no validation overhead and no Zod in your bundle.

Querying

schema-idb exposes IndexedDB's single-index model directly, with type safety.

Object style

const users = await db.users.query({
  index: "age",
  where: { gte: 18 },
});

const recent = await db.users.query({
  index: "createdAt",
  where: { gte: lastWeek, lte: today },
  orderBy: "desc",
  limit: 20,
});

const kims = await db.users.query({
  index: "name",
  where: { startsWith: "Kim" },
});

Builder style

const users = await db.users
  .query()
  .index("age")
  .gte(18)
  .orderBy("desc")
  .limit(10)
  .findAll();

const user = await db.users
  .query()
  .index("email")
  .equals("[email protected]")
  .find();

Where conditions

| Condition | Description | | ------------ | -------------------------- | | eq | Equals | | gt / gte | Greater than (or equal) | | lt / lte | Less than (or equal) | | between | Inclusive range | | startsWith | Prefix match (string only) |


Transactions

schema-idb exposes transactions as synchronous write batches across multiple stores.

// Single store
const tx = db.startTransaction("accounts");

// Multiple stores
const tx = db.startTransaction(["accounts", "logs"]);

// Queue operations (no await between them)
tx.accounts.put({ id: "a1", balance: 900 });
tx.accounts.put({ id: "a2", balance: 1100 });
tx.logs.put({ id: "log1", action: "transfer", amount: 100 });

// Commit all at once
await tx.commit();

// Or abort
tx.abort();

// Access underlying IDBTransaction if needed
tx.raw;

Read operations are not available inside transactions. IndexedDB transactions auto-commit after any await, so schema-idb only supports synchronous write batching.


Migrations

Run code when the database version changes.

const usersStore = defineStore("users", {
  id: field.string().primaryKey(),
  name: field.string(),
  email: field.string().index({ unique: true }),
})
  .addMigration("001-seed-admin", (db, tx) => {
    tx.objectStore("users").put({
      id: "admin",
      name: "Admin",
      email: "[email protected]",
    });
  })
  .addMigration("002-normalize-emails", (db, tx) => {
    // Data transformation logic
  });

Migrations are identified by name and run in alphabetical order. Applied migrations are tracked and skipped automatically.


Schema Evolution

Add fields over time. Defaults are injected at write time — when a record is created or updated via put/add — so they persist in the database.

// Original
const usersStore = defineStore("users", {
  id: field.string().primaryKey(),
  name: field.string(),
});

// Additive change
const usersStore = defineStore("users", {
  id: field.string().primaryKey(),
  name: field.string(),
  role: field.string().optional().default("user"),
});

// New records get the default on write
await db.users.put({ id: "u1", name: "Kim" });
const fresh = await db.users.get("u1");
console.log(fresh?.role); // 'user'

Records written before the field existed do not gain the default on read. Because defaults are injected on write (not on read), older records keep whatever they were stored with. To backfill existing rows, use a migration — and consider a validation resolver to catch records that no longer match the current schema.

Output types reflect this: an optional() field is always T | undefined on read, even with a default, since a missing field can legitimately read back as undefined (e.g. from older records or raw writes).


Automatic Versioning

Let schema-idb derive versions from your schema.

const db = openDB({
  name: "MyApp",
  versionStrategy: "auto",
  stores: [usersStore] as const,
});

Auto-applied changes (safe)

  • New stores
  • New indexes
  • Index modifications
  • Index deletions

Requires manual migration (throws error by default)

  • Store deletions (data loss)
  • keyPath changes (requires store recreation)

Handling removed stores

When a store is removed from the schema, you can choose how to handle it:

const db = openDB({
  name: "MyApp",
  versionStrategy: "auto",
  removedStoreStrategy: "preserve", // See options below
  stores: [usersStore] as const,
});

| Strategy | Behavior | | -------- | -------- | | 'error' | Throws an error (default) | | 'preserve' | Renames to __storeName_deleted_v{version}__ as backup | | 'drop' | Deletes the store and its data | | 'ignore' | Keeps the store as-is (no changes) |

Behavior with explicit versioning

When versionStrategy is "explicit":

  • Schema changes are detected but NOT applied automatically
  • removedStoreStrategy is evaluated for preview purposes only
  • A warning is logged if schema changes are detected but version is not bumped
[schema-idb] Schema changes detected but version not bumped:
- Rename store "oldStore" to "__oldStore_deleted_v2__"
Current DB version: 1, Provided version: 1
Bump the version to apply these changes.

Important: removedStoreStrategy does not perform migrations in explicit mode. It only describes what would happen after a version bump. To apply the changes, increment the version number.

To explicitly delete a store (including backups), use a migration:

const usersStore = defineStore("users", {
  // ...
}).addMigration("003-delete-old-store", (db) => {
  db.deleteObjectStore("oldStore");
  db.deleteObjectStore("__oldStore_deleted_v2__"); // Remove backup too
});

Type Inference

Extract TypeScript types from your schema.

import type { InferStore } from "schema-idb";

type User = InferStore<typeof usersStore>;

API Reference

This section is intended as a complete, authoritative reference. Most users will not need to read it top-to-bottom. For a guided introduction and examples, see the sections above.

openDB

Opens a database connection with the given configuration.

function openDB<T extends readonly SchemaStoreDefinition[]>(options: {
  name: string;
  stores: T;
  versionStrategy?: "auto" | "explicit";
  version?: number;
  removedStoreStrategy?: "error" | "preserve" | "drop" | "ignore";
}): SchemaDatabase<T>;

| Option | Type | Description | | ------ | ---- | ----------- | | name | string | Database name | | stores | readonly SchemaStoreDefinition[] | Store definitions created with defineStore | | versionStrategy | "auto" \| "explicit" | "auto" detects schema changes automatically. Default: "explicit" (recommended for production control) | | version | number | Required when versionStrategy is "explicit" | | removedStoreStrategy | "error" \| "preserve" \| "drop" \| "ignore" | How to handle removed stores. Default: "error" |

SchemaDatabase

The database object returned by openDB.

| Property | Type | Description | | -------- | ---- | ----------- | | name | string | Database name | | version | number | Current schema version | | ready | boolean | Whether the database is ready | | raw | IDBDatabase | Underlying IndexedDB instance | | [storeName] | StoreAccessor | Direct access to stores (e.g., db.users) |

| Method | Signature | Description | | ------ | --------- | ----------- | | waitForReady | () => Promise<void> | Wait for database initialization | | close | () => void | Close the database connection | | startTransaction | (stores, options?) => Transaction | Start a multi-store transaction |

Store Accessor

Each store is accessible as a property on the database object (e.g., db.users).

get

get(key: K): Promise<T | undefined>

| Param | Type | Description | | ----- | ---- | ----------- | | key | K | Primary key value |

Returns the record matching the key, or undefined if not found.

getAll

getAll(): Promise<T[]>

Returns all records in the store.

getBy

getBy<I extends IndexedFields>(indexName: I, query: V | IDBKeyRange): Promise<T | undefined>

| Param | Type | Description | | ----- | ---- | ----------- | | indexName | I | Name of the index to query | | query | V \| IDBKeyRange | Value to match or key range |

Returns the first record matching the index value.

getAllBy

getAllBy<I extends IndexedFields>(indexName: I, query?: V | IDBKeyRange): Promise<T[]>

| Param | Type | Description | | ----- | ---- | ----------- | | indexName | I | Name of the index to query | | query | V \| IDBKeyRange | Value to match or key range (optional) |

Returns all records matching the index value. If query is omitted, returns all records ordered by the index.

put

put(value: T, key?: K): Promise<K>

| Param | Type | Description | | ----- | ---- | ----------- | | value | T | Record to insert or update | | key | K | Optional key (only needed if store has no keyPath) |

Inserts a new record or updates an existing one. Returns the primary key.

add

add(value: T, key?: K): Promise<K>

| Param | Type | Description | | ----- | ---- | ----------- | | value | T | Record to insert | | key | K | Optional key (only needed if store has no keyPath) |

Inserts a new record. Throws an error if the key already exists.

delete

delete(key: K | IDBKeyRange): Promise<void>

| Param | Type | Description | | ----- | ---- | ----------- | | key | K \| IDBKeyRange | Primary key or key range to delete |

Deletes record(s) matching the key or range.

clear

clear(): Promise<void>

Deletes all records in the store.

count

count(query?: IDBKeyRange | IDBValidKey): Promise<number>

| Param | Type | Description | | ----- | ---- | ----------- | | query | IDBKeyRange \| IDBValidKey | Optional key or range to count |

Returns the number of records. If query is provided, counts only matching records.

query

query(options: QueryOptions): Promise<T[]>
query(): QueryBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | options | QueryOptions | Query configuration (optional) |

When called with options, executes the query and returns results. When called without arguments, returns a QueryBuilder for chaining.

Query Options

Used with db.store.query(options).

interface QueryOptions {
  index: string;
  where?: WhereCondition;
  orderBy?: "asc" | "desc";
  limit?: number;
  offset?: number;
}

| Option | Type | Description | | ------ | ---- | ----------- | | index | string | Index name to query on | | where | WhereCondition | Filter conditions (optional) | | orderBy | "asc" \| "desc" | Sort order. Default: "asc" | | limit | number | Maximum number of results (optional) | | offset | number | Number of results to skip. Default: 0 |

WhereCondition

interface WhereCondition {
  eq?: T;
  gt?: T;
  gte?: T;
  lt?: T;
  lte?: T;
  between?: [T, T];
  startsWith?: string;
}

| Option | Type | Description | | ------ | ---- | ----------- | | eq | T | Exact match | | gt | T | Greater than | | gte | T | Greater than or equal | | lt | T | Less than | | lte | T | Less than or equal | | between | [T, T] | Inclusive range [lower, upper] | | startsWith | string | Prefix match (string indexes only) |

Query Builder

Returned when calling db.store.query() without arguments.

index

index<I extends IndexedFields>(name: I): IndexQueryBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | name | I | Index name to query on |

Returns an IndexQueryBuilder for the specified index.

key

key(): IndexQueryBuilder

Returns an IndexQueryBuilder that queries by primary key.

findAll

findAll(): Promise<T[]>

Executes the query and returns all matching records.

IndexQueryBuilder

Provides condition methods for filtering. All methods return a FinalQueryBuilder.

equals

equals(value: V): FinalQueryBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | value | V | Value to match exactly |

gt / gte / lt / lte

gt(value: V): FinalQueryBuilder   // Greater than
gte(value: V): FinalQueryBuilder  // Greater than or equal
lt(value: V): FinalQueryBuilder   // Less than
lte(value: V): FinalQueryBuilder  // Less than or equal

| Param | Type | Description | | ----- | ---- | ----------- | | value | V | Boundary value for comparison |

between

between(lower: V, upper: V): FinalQueryBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | lower | V | Lower bound (inclusive) | | upper | V | Upper bound (inclusive) |

startsWith

startsWith(prefix: string): FinalQueryBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | prefix | string | Prefix to match |

Only available for string indexes.

FinalQueryBuilder

Provides result modifiers and execution methods.

orderBy

orderBy(order: "asc" | "desc"): FinalQueryBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | order | "asc" \| "desc" | Sort direction |

limit

limit(count: number): FinalQueryBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | count | number | Maximum number of results |

offset

offset(count: number): FinalQueryBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | count | number | Number of results to skip |

findAll

findAll(): Promise<T[]>

Executes the query and returns all matching records.

find

find(): Promise<T | undefined>

Executes the query and returns the first matching record, or undefined if none found.

count

count(): Promise<number>

Returns the number of matching records without fetching them.

Transaction

startTransaction

startTransaction(
  stores: string | string[],
  options?: TransactionOptions
): Transaction

| Param | Type | Description | | ----- | ---- | ----------- | | stores | string \| string[] | Store name(s) to include in the transaction | | options | TransactionOptions | Transaction configuration (optional) |

TransactionOptions

| Option | Type | Description | | ------ | ---- | ----------- | | mode | "write" | Transaction mode. Currently only "write" is supported | | durability | "default" \| "strict" \| "relaxed" | Durability hint for the transaction. Default: "default" |

Transaction Object

| Property | Type | Description | | -------- | ---- | ----------- | | raw | IDBTransaction | Underlying IndexedDB transaction | | [storeName] | TransactionStoreAccessor | Synchronous store accessor for each included store |

commit
commit(): Promise<void>

Commits all queued operations and waits for completion.

abort
abort(): void

Aborts the transaction, discarding all queued operations.

TransactionStoreAccessor

Synchronous operations for use within transactions. Operations are queued and executed when commit() is called.

put
put(value: T, key?: K): void

| Param | Type | Description | | ----- | ---- | ----------- | | value | T | Record to insert or update | | key | K | Optional key (only needed if store has no keyPath) |

add
add(value: T, key?: K): void

| Param | Type | Description | | ----- | ---- | ----------- | | value | T | Record to insert | | key | K | Optional key (only needed if store has no keyPath) |

delete
delete(key: K | IDBKeyRange): void

| Param | Type | Description | | ----- | ---- | ----------- | | key | K \| IDBKeyRange | Primary key or key range to delete |

clear
clear(): void

Queues deletion of all records in the store.

defineStore

Creates a store definition with schema.

defineStore<N extends string, S extends StoreSchema>(
  name: N,
  schema: S
): SchemaStoreDefinition<N, S>

| Param | Type | Description | | ----- | ---- | ----------- | | name | N | Store name (used as db.name accessor) | | schema | S | Object defining fields using field builders |

Returns a SchemaStoreDefinition with the following method:

addMigration

addMigration(
  name: string,
  fn: (db: IDBDatabase, tx: IDBTransaction) => void
): this

| Param | Type | Description | | ----- | ---- | ----------- | | name | string | Unique migration identifier (sorted alphabetically) | | fn | MigrationFn | Migration function with access to database and transaction |

Migrations run during version upgrades in alphabetical order by name.

use

use(resolver: StoreResolver): this

| Param | Type | Description | | ----- | ---- | ----------- | | resolver | StoreResolver | A validation resolver, e.g. zodResolver() from schema-idb/resolvers/zod |

Attaches a runtime validation resolver to the store. Records are validated on read; validation failures throw. Returns the builder for chaining. See Runtime Validation.

import { zodResolver } from "schema-idb/resolvers/zod";

const usersStore = defineStore("users", {
  /* ... */
}).use(zodResolver());

field

Field type builders for schema definition.

field.string

field.string(): FieldBuilder<string>

Creates a string field.

field.number

field.number(): FieldBuilder<number>

Creates a number field.

field.boolean

field.boolean(): FieldBuilder<boolean>

Creates a boolean field.

field.date

field.date(): FieldBuilder<Date>

Creates a Date field.

field.object

field.object<S>(shape: S): FieldBuilder<InferObjectType<S>>

| Param | Type | Description | | ----- | ---- | ----------- | | shape | S | Object mapping keys to field builders |

Creates a nested object field. Nested fields use the same field builder, and objects can be nested arbitrarily deep.

field.object({
  street: field.string(),
  city: field.string(),
  zipCode: field.number().optional(),
})

field.tuple

field.tuple<T>(items: T): FieldBuilder<InferTupleType<T>>

| Param | Type | Description | | ----- | ---- | ----------- | | items | T | Array of field builders, one per tuple position |

Creates a fixed-length tuple field. Positional types are preserved.

field.tuple([field.number(), field.number()])  // [number, number]

field.enum

field.enum<T extends readonly string[]>(values: T): FieldBuilder<T[number]>

| Param | Type | Description | | ----- | ---- | ----------- | | values | readonly string[] | Array of allowed string values |

Creates a string union type field.

field.enum(['active', 'inactive', 'pending'] as const)

field.nativeEnum

field.nativeEnum<T extends Record<string, string | number>>(enumObj: T): FieldBuilder<T[keyof T]>

| Param | Type | Description | | ----- | ---- | ----------- | | enumObj | T | TypeScript enum object |

Creates a field from a TypeScript enum.

enum Status { Active = 'active', Inactive = 'inactive' }
field.nativeEnum(Status)

FieldBuilder

Methods available on all field builders. All methods return this for chaining.

primaryKey

primaryKey(): FieldBuilder

Marks the field as the store's primary key. Exactly one field per store must be marked as primary key.

index

index(options?: IndexOptions): FieldBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | options.unique | boolean | If true, enforces unique values. Default: false | | options.multiEntry | boolean | If true, indexes each array element separately. Default: false |

Creates an index on this field, enabling queries via query(), getBy(), and getAllBy().

optional

optional(): FieldBuilder

Marks the field as optional, allowing undefined values.

default

default(value: T | (() => T)): FieldBuilder

| Param | Type | Description | | ----- | ---- | ----------- | | value | T \| (() => T) | Default value or factory function |

Sets a default that is injected at write time (put/add) when the field is omitted from the input, so the value persists in the database. A factory function is called once per write — e.g. () => new Date() produces a distinct timestamp per record.

field.number().default(0)
field.date().default(() => new Date())

A default() does not make an optional() field non-nullable on read: the field can still be undefined (older records, raw writes), so the inferred output type stays T | undefined.

array

array(): FieldBuilder<T[]>

Converts the field type to an array.

field.string().array()  // string[]

Utility Functions

deleteDB

deleteDB(name: string): Promise<void>

| Param | Type | Description | | ----- | ---- | ----------- | | name | string | Database name to delete |

Deletes the database and all its data.

isIndexedDBAvailable

isIndexedDBAvailable(): boolean

Returns true if IndexedDB is available in the current environment.

Type Utilities

InferStore

type InferStore<T> = /* inferred output type from store definition */

Extracts the TypeScript type from a store definition:

const usersStore = defineStore("users", {
  id: field.string().primaryKey(),
  name: field.string(),
  age: field.number().optional().default(0),
});

type User = InferStore<typeof usersStore>;
// { id: string; name: string; age: number | undefined }
//
// `age` is `number | undefined`, not `number`: it is `optional()`, so even with
// a default it may read back as `undefined` (older records / raw writes).
// Defaults are injected on write, not guaranteed on read.

Limitations

  • Single-index queries — IndexedDB does not support compound queries.
  • Synchronous write batches — Transactions cannot include reads (they require await).
  • Immutable keyPath — Changing the primary key requires manual data migration.
  • Browser only — IndexedDB is not available in Node.js. Use fake-indexeddb for testing.

License

MIT