schema-idb
v0.0.9
Published
Type-safe IndexedDB wrapper with chainable transactions
Maintainers
Readme
schema-idb
A type-safe IndexedDB layer that brings structure to client-side storage.
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-idbQuick 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 typeField 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 typeIndexes
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 validationzod is an optional peer dependency. Install it only if you use the resolver:
npm install zodHow it behaves
- Validation runs on
get,getAll,getBy,getAllBy, andquery. Writes are not validated (the input is already typed). - A field is validated as required unless it is
optional(). Adefault()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
removedStoreStrategyis 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(): IndexQueryBuilderReturns 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(): voidAborts 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(): voidQueues 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(): FieldBuilderMarks 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(): FieldBuilderMarks 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 anoptional()field non-nullable on read: the field can still beundefined(older records, raw writes), so the inferred output type staysT | 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(): booleanReturns 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-indexeddbfor testing.
License
MIT
