IndexedDB Adapter
The IndexedDB adapter provides browser-based persistent storage for the content system. It leverages the browser’s IndexedDB API to offer durable, high-performance content storage with sophisticated querying capabilities.
Overview
The IndexedDB adapter enables content storage directly in the browser, facilitating offline applications and improved performance for browser-based content management. It organizes content by URI and supports transactional operations, indexes for efficient queries, and binary data storage.
import { createIndexedDBAdapter } from '@lib/content/adapters/browser/indexed-db'
const adapter = createIndexedDBAdapter({
databaseName: 'content-db',
storeName: 'content-store',
version: 1,
})
API Reference
Creation
function createIndexedDBAdapter(
options?: IndexedDBAdapterOptions
): ContentAdapter
Creates a new IndexedDB adapter instance.
Options
interface IndexedDBAdapterOptions extends ContentAdapterOptions {
/**
* Name of the IndexedDB database (default: 'inherent-content')
*/
databaseName?: string
/**
* Name of the object store (default: 'content')
*/
storeName?: string
/**
* Database version (default: 1)
*/
version?: number
/**
* Callback for database upgrade (optional)
*/
upgradeCallback?: (
db: IDBDatabase,
oldVersion: number,
newVersion: number
) => void
/**
* Whether to use compression for content data (default: false)
*/
useCompression?: boolean
/**
* Maximum number of operations in a batch (default: 50)
*/
maxBatchSize?: number
/**
* Time in ms before considering a database operation failed (default: 5000)
*/
operationTimeout?: number
/**
* Create indexes for faster querying (default: true)
*/
createIndexes?: boolean
/**
* Auto-retry failed operations (default: true)
*/
autoRetry?: boolean
/**
* Maximum retries for failed operations (default: 3)
*/
maxRetries?: number
}
Methods
read
async read(uri: string, options?: ReadOptions): Promise<Content>;
Reads content from the IndexedDB database.
Parameters:
uri
: Content URIoptions
: Optional read configuration
Returns:
- A Promise resolving to a Content object
Example:
const content = await adapter.read('articles/welcome.md')
console.log(content.data) // Content data
console.log(content.metadata) // Associated metadata
Implementation Details:
- Opens a read-only transaction to the content store
- Retrieves content object by URI
- Decompresses data if compression is enabled
- Throws ContentNotFoundError if URI doesn’t exist
- Handles transaction timeouts and database errors
write
async write(uri: string, content: Content, options?: WriteOptions): Promise<void>;
Writes content to the IndexedDB database.
Parameters:
uri
: Content URIcontent
: Content object to writeoptions
: Optional write configuration
Example:
await adapter.write('articles/welcome.md', {
data: '# Welcome\n\nThis is a welcome article.',
contentType: 'text/markdown',
metadata: {
title: 'Welcome',
createdAt: new Date(),
},
})
Implementation Details:
- Opens a read-write transaction to the content store
- Creates or updates content entry by URI
- Compresses data if compression is enabled
- Adds timestamp metadata if not present
- Emits change events if events are enabled
- Handles transaction timeouts and database errors
delete
async delete(uri: string, options?: DeleteOptions): Promise<void>;
Deletes content from the IndexedDB database.
Parameters:
uri
: Content URIoptions
: Optional delete configuration
Example:
await adapter.delete('articles/outdated-article.md')
Implementation Details:
- Opens a read-write transaction to the content store
- Removes content entry by URI
- Emits delete event if events are enabled
- Handles transaction timeouts and database errors
- Returns successfully if content doesn’t exist (idempotent)
list
async list(pattern?: string, options?: ListOptions): Promise<string[]>;
Lists content URIs matching the pattern.
Parameters:
pattern
: Optional glob pattern for content URIsoptions
: Optional list configuration
Returns:
- A Promise resolving to an array of content URIs
Example:
const uris = await adapter.list('articles/**/*.md')
console.log(uris) // ['articles/welcome.md', 'articles/guide.md', ...]
Implementation Details:
- Opens a read-only transaction to the content store
- Uses index if available for more efficient retrieval
- Filters results based on pattern using minimatch
- Supports pagination with limit and offset options
- Handles transaction timeouts and database errors
exists
async exists(uri: string, options?: ExistsOptions): Promise<boolean>;
Checks if content exists in the IndexedDB database.
Parameters:
uri
: Content URIoptions
: Optional exists configuration
Returns:
- A Promise resolving to a boolean indicating existence
Example:
const exists = await adapter.exists('articles/welcome.md')
if (exists) {
console.log('Article exists in database')
}
Implementation Details:
- Opens a read-only transaction to the content store
- Checks for existence without retrieving full content
- Optimized for performance compared to read + catch pattern
- Handles transaction timeouts and database errors
watch
watch(pattern: string, listener: WatchListener): Unsubscribe;
As IndexedDB doesn’t have native change events, this implementation uses a polling mechanism to detect changes.
Parameters:
pattern
: Glob pattern for content URIs to watchlistener
: Callback function for change events
Returns:
- An unsubscribe function to stop watching
Example:
const unsubscribe = adapter.watch(
'articles/**/*.md',
(uri, content, changeType) => {
console.log(`Content ${uri} was ${changeType}`)
}
)
// Later, stop watching
unsubscribe()
Implementation Details:
- Sets up a polling interval to check for changes
- Maintains content digests to detect changes
- Calls listener with change type and content
- Returns function to clear interval and stop polling
dispose
async dispose(): Promise<void>;
Cleans up resources used by the adapter.
Example:
await adapter.dispose()
Implementation Details:
- Closes any open database connections
- Clears any watch polling intervals
- Removes event listeners
- Releases any cached resources
Database Structure
The IndexedDB adapter creates the following database structure:
Database: "inherent-content" (or custom name)
ObjectStore: "content" (or custom name)
Key: Content URI (string)
Value: {
data: string | ArrayBuffer,
contentType: string,
metadata: object
}
Indexes:
- "contentType": For filtering by content type
- "updatedAt": For sorting by modification time
- "path": For hierarchical queries
Transaction Management
The adapter provides robust transaction handling:
async function withTransaction<T>(
mode: 'readonly' | 'readwrite',
callback: (store: IDBObjectStore) => Promise<T>
): Promise<T> {
// Open database
const db = await openDatabase()
// Create transaction
const transaction = db.transaction(storeName, mode)
// Get object store
const store = transaction.objectStore(storeName)
// Set up promise to track completion
return new Promise<T>((resolve, reject) => {
// Set timeout for transaction
const timeoutId = setTimeout(() => {
reject(new ContentError('IndexedDB transaction timeout'))
}, options.operationTimeout)
// Handle transaction completion
transaction.oncomplete = () => {
clearTimeout(timeoutId)
resolve(result)
}
// Handle transaction error
transaction.onerror = event => {
clearTimeout(timeoutId)
reject(handleError(event, 'Transaction failed'))
}
// Execute callback with store
let result: T
try {
Promise.resolve(callback(store))
.then(callbackResult => {
result = callbackResult
})
.catch(error => {
transaction.abort()
clearTimeout(timeoutId)
reject(error)
})
} catch (error) {
transaction.abort()
clearTimeout(timeoutId)
reject(error)
}
})
}
Error Handling
The IndexedDB adapter maps database errors to content errors:
function handleError(
event: Event | DOMException,
message: string,
uri?: string
): ContentError {
const error =
event instanceof DOMException
? event
: (event.target as IDBTransaction).error
switch (error?.name) {
case 'NotFoundError':
return new ContentNotFoundError(uri || '', 'indexeddb')
case 'QuotaExceededError':
return new ContentError(
`Storage quota exceeded: ${message}`,
uri,
'indexeddb',
false
)
case 'VersionError':
return new ContentError(
`Database version mismatch: ${message}`,
uri,
'indexeddb',
true,
{ canRetry: true, suggestedAction: 'reload' }
)
default:
return new ContentError(
`IndexedDB error: ${error?.message || message}`,
uri,
'indexeddb',
true,
{ canRetry: true }
)
}
}
Content Compression
When enabled, the adapter compresses content data to reduce storage requirements:
// Compress content data
async function compressContent(data: string): Promise<ArrayBuffer> {
// Convert string to uint8 array
const encoder = new TextEncoder()
const uint8Data = encoder.encode(data)
// Use CompressionStream if available
if ('CompressionStream' in window) {
const cs = new CompressionStream('gzip')
const writer = cs.writable.getWriter()
writer.write(uint8Data)
writer.close()
const reader = cs.readable.getReader()
const chunks = []
while (true) {
const { value, done } = await reader.read()
if (done) break
chunks.push(value)
}
// Combine chunks
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
return result.buffer
}
// Fallback for browsers without CompressionStream
// Use pako or similar library
return compressWithFallback(uint8Data)
}
// Decompress content data
async function decompressContent(data: ArrayBuffer): Promise<string> {
// Use DecompressionStream if available
if ('DecompressionStream' in window) {
const ds = new DecompressionStream('gzip')
const writer = ds.writable.getWriter()
writer.write(new Uint8Array(data))
writer.close()
const reader = ds.readable.getReader()
const chunks = []
while (true) {
const { value, done } = await reader.read()
if (done) break
chunks.push(value)
}
// Combine chunks
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
// Convert back to string
const decoder = new TextDecoder()
return decoder.decode(result)
}
// Fallback for browsers without DecompressionStream
return decompressWithFallback(data)
}
Batch Operations
The adapter supports batching multiple operations for improved performance:
interface BatchOperation {
type: 'write' | 'delete'
uri: string
content?: Content
}
async function executeBatch(operations: BatchOperation[]): Promise<void> {
// Group operations into chunks to avoid transaction timeouts
const chunks = []
for (let i = 0; i < operations.length; i += options.maxBatchSize) {
chunks.push(operations.slice(i, i + options.maxBatchSize))
}
// Process each chunk in a separate transaction
for (const chunk of chunks) {
await withTransaction('readwrite', async store => {
for (const op of chunk) {
if (op.type === 'write' && op.content) {
// Handle write operation
const value = { ...op.content }
// Compress if needed
if (options.useCompression && typeof value.data === 'string') {
value.data = await compressContent(value.data)
value.compressed = true
}
// Store content
await promisifyRequest(store.put(value, op.uri))
} else if (op.type === 'delete') {
// Handle delete operation
await promisifyRequest(store.delete(op.uri))
}
}
})
}
}
Browser Compatibility
The IndexedDB adapter includes compatibility handling for different browser implementations:
// Check if IndexedDB is supported
function isIndexedDBSupported(): boolean {
return 'indexedDB' in window
}
// Open database with compatibility handling
async function openDatabase(): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((resolve, reject) => {
// Handle vendor prefixes (for older browsers)
const indexedDB =
window.indexedDB ||
(window as any).mozIndexedDB ||
(window as any).webkitIndexedDB ||
(window as any).msIndexedDB
if (!indexedDB) {
reject(new Error('IndexedDB is not supported in this browser'))
return
}
// Open database
const request = indexedDB.open(options.databaseName, options.version)
// Handle upgrade
request.onupgradeneeded = event => {
const db = request.result
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(options.storeName)) {
const store = db.createObjectStore(options.storeName)
// Create indexes if enabled
if (options.createIndexes) {
store.createIndex('contentType', 'contentType', { unique: false })
store.createIndex('updatedAt', 'metadata.updatedAt', {
unique: false,
})
store.createIndex('path', 'path', { unique: false })
}
}
// Call custom upgrade callback if provided
if (options.upgradeCallback) {
options.upgradeCallback(db, event.oldVersion, event.newVersion || 1)
}
}
// Handle success
request.onsuccess = () => {
resolve(request.result)
}
// Handle error
request.onerror = () => {
reject(
new Error(
`Failed to open IndexedDB database: ${request.error?.message}`
)
)
}
})
}
Storage Management
The adapter monitors and manages storage usage:
async function checkStorageUsage(): Promise<{
used: number
quota: number
percentage: number
}> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate()
return {
used: estimate.usage || 0,
quota: estimate.quota || 0,
percentage: estimate.usage ? (estimate.usage / estimate.quota!) * 100 : 0,
}
}
return {
used: 0,
quota: 0,
percentage: 0,
}
}
// Check if we're approaching storage limits
async function isStorageLow(): Promise<boolean> {
const usage = await checkStorageUsage()
return usage.percentage > 90 // Warning at 90% usage
}
Performance Considerations
The IndexedDB adapter includes performance optimizations:
- Indexed Queries: Uses database indexes for efficient filtering
- Connection Pooling: Reuses database connections
- Transaction Batching: Groups operations to reduce overhead
- Compression: Reduces storage size for text content
- Pagination: Supports efficient retrieval of large result sets
- Cursor Usage: Uses cursors for memory-efficient list operations
Environment Compatibility
This adapter is specifically designed for browser environments and will not work in Node.js. For cross-environment code, use environment detection:
import { isBrowser } from '@lib/utils/env'
import {
createIndexedDBAdapter,
createMemoryAdapter,
} from '@lib/content/adapters'
const adapter =
isBrowser() && 'indexedDB' in window
? createIndexedDBAdapter({ databaseName: 'content-db' })
: createMemoryAdapter()