@livequery/indexeddb
v1.0.0
Published
IndexedDB storage adapter for @livequery/client
Readme
@livequery/indexeddb
Persistent IndexedDB storage adapter for @livequery/client.
Replaces the built-in LivequeryMemoryStorage with a real browser database, so data survives page reloads and tab switches — with zero dependencies beyond @livequery/client itself. Works natively in any modern browser without WebAssembly or native modules.
Why this package?
| Feature | LivequeryMemoryStorage | @livequery/indexeddb | @livequery/sqlite |
|---|---|---|---|
| Data survives reload | ❌ | ✅ | ✅ |
| cache-first mode useful | ❌ | ✅ | ✅ |
| Offline reads | ❌ | ✅ | ✅ |
| Zero extra dependencies | ✅ | ✅ | ❌ (needs WASM) |
| Works in browser | ✅ | ✅ | ✅ (dedicated worker) |
| Works in React Native | ✅ | ❌ | ✅ (via native driver) |
Use @livequery/indexeddb when you need persistence in the browser with no build complexity. Use @livequery/sqlite when you also need React Native support or need the structured query power of SQL.
Installation
# npm
npm install @livequery/indexeddb @livequery/client
# bun
bun add @livequery/indexeddb @livequery/clientHow it works
Each collection gets its own IndexedDB object store, created on first access. Documents are stored directly via the browser's structured clone algorithm — no JSON serialisation needed.
Storage architecture
IndexedDB database: "livequery" (configurable)
├── Object store: "users" ← one store per collection
├── Object store: "posts"
└── Object store: "settings"
Each record = the document itself:
{ id: "abc123", name: "Ba", _adding: true, ... }Dynamic store creation
IndexedDB only allows creating object stores inside an onupgradeneeded handler, which requires a database version bump. This package handles it automatically: when a new collection is accessed for the first time, all pending operations are paused, the database is closed, reopened at version + 1 with the new store created, then operations resume.
This is safe because the livequery client is designed to run inside a Shared Worker — only one database connection exists at any time, so no race conditions between tabs.
Promise queue
All operations are serialised through an internal #pending promise chain (a standard mutex/queue pattern). This guarantees:
- No read or write runs while a store upgrade is in progress
- Multiple collections first accessed simultaneously are created sequentially, each waiting for the previous upgrade to finish
Usage
import { LivequeryIndexedDBStorage } from '@livequery/indexeddb'
import { LivequeryClient } from '@livequery/client'
const client = new LivequeryClient({
storage: new LivequeryIndexedDBStorage(),
transporters: { /* ... */ }
})Custom database name
Useful when running multiple independent LivequeryClient instances in the same origin:
new LivequeryIndexedDBStorage({ dbName: 'myapp-v2' })Full example — React
// client.ts
import { LivequeryIndexedDBStorage } from '@livequery/indexeddb'
import { LivequeryClient } from '@livequery/client'
import { myTransporter } from './transporter'
export const client = new LivequeryClient({
storage: new LivequeryIndexedDBStorage({ dbName: 'myapp' }),
transporters: { api: myTransporter }
})// App.tsx
import { LivequeryClientProvider } from '@livequery/react'
import { client } from './client'
export function App() {
return (
<LivequeryClientProvider client={client}>
<TodoList />
</LivequeryClientProvider>
)
}// TodoList.tsx
import { useCollection } from '@livequery/react'
type Todo = { id: string; title: string; done: boolean; createdAt: number }
export function TodoList() {
const collection = useCollection<Todo>('todos', { mode: 'cache-first' })
return (
<ul>
{collection.items.map(doc => (
<li key={doc.value.id}>
<input
type="checkbox"
checked={doc.value.done}
onChange={() => doc.update({ done: !doc.value.done })}
/>
{doc.value.title}
<button onClick={() => doc.del()}>Delete</button>
</li>
))}
</ul>
)
}API Reference
LivequeryIndexedDBStorage
new LivequeryIndexedDBStorage(options?: LivequeryIndexedDBStorageOptions)Implements the full LivequeryStorge interface from @livequery/client.
| Method | Description |
|---|---|
| query(collection, filters?) | Returns filtered + sorted documents and a paging object |
| get(ref, id) | Returns one document by id, or null |
| add(collection, document) | Inserts a document; generates a local:<uuidv7> id if none provided |
| update(collection, id, patch) | Merges patch fields into the existing document; handles id changes atomically |
| delete(collection, id) | Deletes and returns the removed document, or null |
| flush() | Clears all documents from the store (all collections) |
LivequeryIndexedDBStorageOptions
| Option | Type | Default | Description |
|---|---|---|---|
| dbName | string | "livequery" | IndexedDB database name |
Supported filter operators
All filter operators from @livequery/client are supported. Filtering and sorting are applied in-memory using the same filterDocs helper used by LivequeryMemoryStorage:
| Operator | Example | Description |
|---|---|---|
| eq (default) | { status: 'active' } | Strict equality |
| eq-number | { age:eq-number: 30 } | Numeric equality |
| gt / gte | { score:gt: 100 } | Greater than / or equal |
| lt / lte | { price:lt: 50 } | Less than / or equal |
| in | { role:in: ['admin', 'mod'] } | Value in array |
| nin | { role:nin: ['banned'] } | Value not in array |
| include | { tags:include: 'news' } | Array field contains value |
| like | { name:like: 'van' } | Case-insensitive substring match |
| boolean | { active:boolean: 'true' } | Boolean match |
| null | { deletedAt:null: 'null-only' } | Null / not-null check |
| sort | { createdAt:sort: 'desc' } | Sort direction |
Paging filters (:limit, :page, :before, :after, :around) are passed through but not yet applied at the storage level.
Browser requirements
IndexedDB is supported in all modern browsers (Chrome 24+, Firefox 16+, Safari 10+, Edge 12+). No WebAssembly, no Workers, no additional headers required.
License
MIT
