Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions packages/indexeddb-db-collection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
# @tanstack/indexeddb-db-collection

**IndexedDB-backed collections for TanStack DB**

Persistent local storage with automatic cross-tab synchronization for TanStack DB collections. Data persists across browser sessions and stays in sync across all open tabs.

## Installation

```bash
npm install @tanstack/indexeddb-db-collection @tanstack/db
```

## Quick Start

```typescript
import { createCollection } from '@tanstack/db'
import { createIndexedDB, indexedDBCollectionOptions } from '@tanstack/indexeddb-db-collection'

interface Todo {
id: string
text: string
completed: boolean
}

// Step 1: Create the database with all stores defined upfront
const db = await createIndexedDB({
name: 'myApp',
version: 1,
stores: ['todos'],
})

// Step 2: Create collections using the shared database
const todosCollection = createCollection<Todo>(
indexedDBCollectionOptions({
db,
name: 'todos',
getKey: (todo) => todo.id,
})
)
```

## Features

- **Persistent Storage** - Data survives browser refreshes and sessions
- **Cross-Tab Sync** - Changes automatically propagate to all open tabs via BroadcastChannel
- **Multiple Collections** - Share a single database across multiple collections
- **Schema Validation** - Optional schema support with Standard Schema (Zod, Valibot, etc.)
- **Full TypeScript Support** - Complete type inference for items and keys
- **Utility Functions** - Export, import, clear, and inspect your data

## Usage

### Multiple Collections Sharing a Database

```typescript
import { createCollection } from '@tanstack/db'
import { createIndexedDB, indexedDBCollectionOptions } from '@tanstack/indexeddb-db-collection'

// Create database with all stores at once
const db = await createIndexedDB({
name: 'myApp',
version: 1,
stores: ['todos', 'users', 'settings'],
})

// Create multiple collections using the same database
const todosCollection = createCollection(
indexedDBCollectionOptions({
db,
name: 'todos',
getKey: (todo) => todo.id,
})
)

const usersCollection = createCollection(
indexedDBCollectionOptions({
db,
name: 'users',
getKey: (user) => user.id,
})
)

const settingsCollection = createCollection(
indexedDBCollectionOptions({
db,
name: 'settings',
getKey: (setting) => setting.key,
})
)
```

### With Schema Validation

```typescript
import { z } from 'zod'
import { createCollection } from '@tanstack/db'
import { createIndexedDB, indexedDBCollectionOptions } from '@tanstack/indexeddb-db-collection'

const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
})

const db = await createIndexedDB({
name: 'myApp',
version: 1,
stores: ['todos'],
})

const todosCollection = createCollection(
indexedDBCollectionOptions({
db,
name: 'todos',
schema: todoSchema,
getKey: (todo) => todo.id,
})
)
```

### Configuration Options

#### createIndexedDB Options

```typescript
const db = await createIndexedDB({
// Required: Name of the IndexedDB database
name: 'myApp',

// Required: Schema version (increment when adding/removing stores)
version: 1,

// Required: Object store names to create
stores: ['todos', 'users'],

// Optional: Custom IDBFactory for testing
idbFactory: fakeIndexedDB,
})
```

#### indexedDBCollectionOptions

```typescript
indexedDBCollectionOptions({
// Required: IndexedDB instance from createIndexedDB()
db,

// Required: Name of the object store within the database
name: 'todos',

// Required: Function to extract the unique key from each item
getKey: (item) => item.id,

// Optional: Schema for validation (Standard Schema compatible)
schema: todoSchema,
})
```

### Utility Functions

The collection exposes utility functions via `collection.utils`:

```typescript
// Clear all data from the object store
await todosCollection.utils.clearObjectStore()

// Delete the entire database
await todosCollection.utils.deleteDatabase()

// Get database info for debugging
const info = await todosCollection.utils.getDatabaseInfo()
// { name: 'myApp', version: 1, objectStores: ['todos', '_versions'] }

// Export all data as an array
const backup = await todosCollection.utils.exportData()

// Import data (clears existing data first)
await todosCollection.utils.importData([
{ id: '1', text: 'Buy milk', completed: false },
{ id: '2', text: 'Walk dog', completed: true },
])

// Accept mutations from a manual transaction
await todosCollection.utils.acceptMutations({ mutations })
```

## Low-Level API

For advanced use cases, the package also exports low-level IndexedDB utilities:

```typescript
import {
openDatabase,
executeTransaction,
getAll,
getByKey,
put,
deleteByKey,
clear,
deleteDatabase,
} from '@tanstack/indexeddb-db-collection'

// Open a database with custom upgrade logic
const db = await openDatabase('myApp', 1, (db, oldVersion) => {
if (oldVersion < 1) {
db.createObjectStore('items', { keyPath: 'id' })
}
})

// Execute operations within a transaction
await executeTransaction(db, 'items', 'readwrite', async (tx, stores) => {
await put(stores.items, { id: '1', value: 'hello' })
const item = await getByKey(stores.items, '1')
})
```

## Error Handling

The package provides specific error classes for different failure scenarios:

```typescript
import {
// Low-level IndexedDB errors
IndexedDBError,
IndexedDBNotSupportedError,
IndexedDBConnectionError,
IndexedDBTransactionError,
IndexedDBOperationError,
// Configuration errors
DatabaseRequiredError,
ObjectStoreNotFoundError,
NameRequiredError,
GetKeyRequiredError,
} from '@tanstack/indexeddb-db-collection'
```

## Cross-Tab Synchronization

Changes made in one tab automatically sync to other tabs via the BroadcastChannel API. Each tab maintains an in-memory version cache to detect changes efficiently.

```typescript
// Tab 1: Insert a todo
todosCollection.insert({ id: '1', text: 'Buy milk', completed: false })

// Tab 2: Automatically receives the update via BroadcastChannel
// No additional code needed - the collection state stays in sync
```

## Testing

When testing, pass a custom `idbFactory` (e.g., from `fake-indexeddb`):

```typescript
import { indexedDB } from 'fake-indexeddb'

const db = await createIndexedDB({
name: 'test-db',
version: 1,
stores: ['items'],
idbFactory: indexedDB,
})
```

## License

MIT
47 changes: 47 additions & 0 deletions packages/indexeddb-db-collection/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@tanstack/indexeddb-db-collection",
"version": "0.0.2",
"description": "IndexedDB collection for TanStack DB",
"author": "Ravi Atluri",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/TanStack/db.git",
"directory": "packages/indexeddb-db-collection"
},
"homepage": "https://tanstack.com/db",
"keywords": ["indexeddb", "tanstack", "optimistic", "typescript", "offline"],
"scripts": {
"build": "vite build",
"dev": "vite build --watch",
"lint": "eslint . --fix",
"test": "vitest run"
},
"type": "module",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": ["dist", "src"],
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@tanstack/db": "workspace:*"
},
"devDependencies": {
"@vitest/coverage-istanbul": "^3.2.4",
"fake-indexeddb": "^6.0.0"
}
}
82 changes: 82 additions & 0 deletions packages/indexeddb-db-collection/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export class IndexedDBError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options)
this.name = `IndexedDBError`
}
}

/**
* Thrown when IndexedDB is not available in the environment.
* This can happen in server-side rendering, older browsers,
* or when running in contexts where IndexedDB is disabled.
*/
export class IndexedDBNotSupportedError extends IndexedDBError {
constructor() {
super(`IndexedDB is not supported in this environment`)
this.name = `IndexedDBNotSupportedError`
}
}

/**
* Thrown when a database connection fails.
* Includes the database name and the underlying error as cause.
*/
export class IndexedDBConnectionError extends IndexedDBError {
databaseName: string

constructor(databaseName: string, cause?: unknown) {
super(`Failed to connect to IndexedDB database "${databaseName}"`, {
cause,
})
this.name = `IndexedDBConnectionError`
this.databaseName = databaseName
}
}

/**
* Thrown when a transaction fails.
* Includes the transaction mode and store names for context.
*/
export class IndexedDBTransactionError extends IndexedDBError {
mode: IDBTransactionMode
storeNames: Array<string>

constructor(
mode: IDBTransactionMode,
storeNames: Array<string>,
cause?: unknown,
) {
const storeNamesStr =
storeNames.length === 1
? `store "${storeNames[0]}"`
: `stores [${storeNames.map((s) => `"${s}"`).join(`, `)}]`
super(`IndexedDB transaction failed in "${mode}" mode on ${storeNamesStr}`, {
cause,
})
this.name = `IndexedDBTransactionError`
this.mode = mode
this.storeNames = storeNames
}
}

/**
* Thrown when a CRUD operation fails.
* Includes the operation type and store name for context.
*/
export class IndexedDBOperationError extends IndexedDBError {
operation: `get` | `put` | `delete` | `clear`
storeName: string

constructor(
operation: `get` | `put` | `delete` | `clear`,
storeName: string,
cause?: unknown,
) {
super(`IndexedDB "${operation}" operation failed on store "${storeName}"`, {
cause,
})
this.name = `IndexedDBOperationError`
this.operation = operation
this.storeName = storeName
}
}
Loading