Skip to content

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.

typescript
import { createIndexedDBAdapter } from '@lib/content/adapters/browser/indexed-db'

const adapter = createIndexedDBAdapter({
  databaseName: 'content-db',
  storeName: 'content-store',
  version: 1,
})

API Reference

Creation

typescript
function createIndexedDBAdapter(
  options?: IndexedDBAdapterOptions
): ContentAdapter

Creates a new IndexedDB adapter instance.

Options

typescript
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

typescript
async read(uri: string, options?: ReadOptions): Promise<Content>;

Reads content from the IndexedDB database.

Parameters:

  • uri: Content URI
  • options: Optional read configuration

Returns:

  • A Promise resolving to a Content object

Example:

typescript
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

typescript
async write(uri: string, content: Content, options?: WriteOptions): Promise<void>;

Writes content to the IndexedDB database.

Parameters:

  • uri: Content URI
  • content: Content object to write
  • options: Optional write configuration

Example:

typescript
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

typescript
async delete(uri: string, options?: DeleteOptions): Promise<void>;

Deletes content from the IndexedDB database.

Parameters:

  • uri: Content URI
  • options: Optional delete configuration

Example:

typescript
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

typescript
async list(pattern?: string, options?: ListOptions): Promise<string[]>;

Lists content URIs matching the pattern.

Parameters:

  • pattern: Optional glob pattern for content URIs
  • options: Optional list configuration

Returns:

  • A Promise resolving to an array of content URIs

Example:

typescript
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

typescript
async exists(uri: string, options?: ExistsOptions): Promise<boolean>;

Checks if content exists in the IndexedDB database.

Parameters:

  • uri: Content URI
  • options: Optional exists configuration

Returns:

  • A Promise resolving to a boolean indicating existence

Example:

typescript
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

typescript
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 watch
  • listener: Callback function for change events

Returns:

  • An unsubscribe function to stop watching

Example:

typescript
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

typescript
async dispose(): Promise<void>;

Cleans up resources used by the adapter.

Example:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
import { isBrowser } from '@lib/utils/env'
import {
  createIndexedDBAdapter,
  createMemoryAdapter,
} from '@lib/content/adapters'

const adapter =
  isBrowser() && 'indexedDB' in window
    ? createIndexedDBAdapter({ databaseName: 'content-db' })
    : createMemoryAdapter()

Released under the MIT License.