Skip to content

Adapter Implementation Patterns

This document describes patterns for implementing storage adapters in the ReX content system. Storage adapters provide the abstraction layer between the content operations and the actual storage mechanisms, allowing the content system to work with various backends.

Adapter Factory Pattern

Pattern Overview

The Adapter Factory pattern creates adapter instances with appropriate configuration for the current environment.

Implementation Example

typescript
import { createAdapter } from '@lib/content/adapters/base'
import { isNode, isBrowser, hasIndexedDB } from '@lib/utils/env'

export function createOptimalAdapter(options = {}) {
  if (isNode()) {
    // Node.js environment - use filesystem adapter
    return createFilesystemAdapter(options)
  } else if (isBrowser()) {
    if (hasIndexedDB()) {
      // Browser with IndexedDB support
      return createIndexedDBAdapter(options)
    } else {
      // Fallback to localStorage
      return createLocalStorageAdapter(options)
    }
  } else if (isServiceWorker()) {
    // Service worker environment
    return createServiceWorkerAdapter(options)
  }

  // Fallback to memory adapter if environment is unknown
  return createMemoryAdapter(options)
}

Considerations

  • Always provide a fallback adapter for unsupported environments
  • Use capability detection to choose the most appropriate adapter
  • Consider environmental constraints (e.g., storage limits, permissions)

Adapter Interface Implementation Pattern

Pattern Overview

This pattern ensures consistent implementation of the adapter interface across different storage backends.

Implementation Example

typescript
import { createAdapter, AdapterInterface } from '@lib/content/adapters/base'
import { createEventEmitter } from '@lib/utils/events'
import { ContentError, ContentNotFoundError } from '@lib/errors/content'

export function createCustomAdapter(options = {}): AdapterInterface {
  // Initialize storage backend with options
  const storage = initializeStorage(options)
  const events = createEventEmitter()

  return createAdapter({
    // Required core operations
    read: async uri => {
      try {
        const data = await storage.get(mapUriToStorageKey(uri))
        if (!data) {
          throw new ContentNotFoundError(uri)
        }

        return {
          data: data.content,
          contentType: data.type || 'text/plain',
          metadata: data.meta || {},
        }
      } catch (error) {
        // Normalize errors
        if (error.code === 'NOT_FOUND') {
          throw new ContentNotFoundError(uri)
        }
        throw new ContentError(`Failed to read ${uri}`, { cause: error })
      }
    },

    write: async (uri, content) => {
      try {
        await storage.set(mapUriToStorageKey(uri), {
          content: content.data,
          type: content.contentType,
          meta: content.metadata,
          updated: new Date().toISOString(),
        })

        // Emit change event after successful write
        events.emit('change', {
          uri,
          content,
          type: 'write',
        })
      } catch (error) {
        throw new ContentError(`Failed to write ${uri}`, { cause: error })
      }
    },

    delete: async uri => {
      try {
        await storage.delete(mapUriToStorageKey(uri))

        // Emit change event after successful delete
        events.emit('change', {
          uri,
          content: null,
          type: 'delete',
        })
      } catch (error) {
        throw new ContentError(`Failed to delete ${uri}`, { cause: error })
      }
    },

    list: async pattern => {
      try {
        const keys = await storage.keys()
        const uris = keys
          .map(mapStorageKeyToUri)
          .filter(uri => matchesPattern(uri, pattern))

        return uris
      } catch (error) {
        throw new ContentError(`Failed to list content`, { cause: error })
      }
    },

    // Event emitter for change notifications
    events,

    // Optional methods for cleanup
    dispose: async () => {
      // Clean up resources
      await storage.close()
      events.removeAllListeners()
    },
  })
}

// Helper functions
function mapUriToStorageKey(uri) {
  // Convert content URI to storage-specific key
  return uri.replace(/\//g, ':')
}

function mapStorageKeyToUri(key) {
  // Convert storage-specific key back to content URI
  return key.replace(/:/g, '/')
}

function matchesPattern(uri, pattern) {
  // Match URI against a glob-like pattern
  // Implementation depends on pattern syntax
  return new RegExp(pattern.replace(/\*/g, '.*')).test(uri)
}

Considerations

  • Implement all required methods of the adapter interface
  • Normalize errors from the underlying storage system
  • Emit consistent change events for write and delete operations
  • Include cleanup logic in the dispose method to prevent resource leaks

Adapter Composition Pattern

Pattern Overview

The Adapter Composition pattern combines multiple adapters to create enhanced functionality.

Implementation Example

typescript
import { createAdapter } from '@lib/content/adapters/base'

export function createCascadingAdapter(adapters, options = {}) {
  // Create a composite events emitter
  const events = createCompositeEventEmitter(adapters.map(a => a.events))

  return createAdapter({
    read: async uri => {
      let lastError

      // Try each adapter in sequence
      for (const adapter of adapters) {
        try {
          return await adapter.read(uri)
        } catch (error) {
          lastError = error
          // Continue to next adapter if not found
          if (error instanceof ContentNotFoundError) {
            continue
          }
          // Rethrow other errors
          throw error
        }
      }

      // If we get here, content wasn't found in any adapter
      throw lastError || new ContentNotFoundError(uri)
    },

    write: async (uri, content) => {
      // Write to the primary adapter (first in the list)
      await adapters[0].write(uri, content)
    },

    delete: async uri => {
      // Delete from all adapters
      await Promise.all(
        adapters.map(adapter =>
          adapter.delete(uri).catch(e => {
            // Ignore not found errors during delete
            if (!(e instanceof ContentNotFoundError)) {
              throw e
            }
          })
        )
      )
    },

    list: async pattern => {
      // Combine and deduplicate results from all adapters
      const results = await Promise.all(
        adapters.map(adapter => adapter.list(pattern))
      )

      // Flatten and deduplicate
      return [...new Set(results.flat())]
    },

    events,

    dispose: async () => {
      // Dispose all adapters
      await Promise.all(adapters.map(adapter => adapter.dispose()))
    },
  })
}

// Helper to combine multiple event emitters
function createCompositeEventEmitter(emitters) {
  const compositeEmitter = createEventEmitter()

  // Forward all events from child emitters
  emitters.forEach(emitter => {
    emitter.on('change', event => {
      compositeEmitter.emit('change', event)
    })
  })

  return compositeEmitter
}

Considerations

  • Define a clear strategy for handling operations across multiple adapters
  • Properly merge results from multiple sources
  • Forward events from all underlying adapters
  • Ensure proper cleanup of all composed adapters

Error Normalization Pattern

Pattern Overview

The Error Normalization pattern converts storage-specific errors into standard content system errors.

Implementation Example

typescript
import {
  ContentError,
  ContentNotFoundError,
  ContentAccessError,
  ContentValidationError,
} from '@lib/errors/content'

function normalizeStorageError(error, operation, uri) {
  // Already a content error - pass through
  if (error instanceof ContentError) {
    return error
  }

  // Filesystem errors (Node.js)
  if (error.code === 'ENOENT') {
    return new ContentNotFoundError(uri)
  }

  if (error.code === 'EACCES' || error.code === 'EPERM') {
    return new ContentAccessError(`No permission to ${operation} ${uri}`, {
      cause: error,
    })
  }

  // IndexedDB errors (Browser)
  if (error.name === 'NotFoundError') {
    return new ContentNotFoundError(uri)
  }

  if (error.name === 'QuotaExceededError') {
    return new ContentAccessError(
      `Storage quota exceeded when trying to ${operation} ${uri}`,
      {
        cause: error,
        recoverable: false,
      }
    )
  }

  // HTTP errors (Fetch API)
  if (error.status === 404) {
    return new ContentNotFoundError(uri)
  }

  if (error.status === 403) {
    return new ContentAccessError(`Forbidden to ${operation} ${uri}`, {
      cause: error,
    })
  }

  if (error.status === 400) {
    return new ContentValidationError(
      `Invalid request to ${operation} ${uri}`,
      {
        cause: error,
      }
    )
  }

  // Default case - wrap in generic ContentError
  return new ContentError(`Failed to ${operation} ${uri}`, {
    cause: error,
  })
}

Considerations

  • Map storage-specific error codes to appropriate content error types
  • Preserve the original error as the cause for debugging
  • Include contextual information about the operation and URI
  • Indicate whether errors are potentially recoverable

Event Emission Pattern

Pattern Overview

The Event Emission pattern standardizes how adapters notify about content changes.

Implementation Example

typescript
import { createEventEmitter } from '@lib/utils/events'

// Create an event emitter for the adapter
const events = createEventEmitter()

// Emit standardized change events
function emitChangeEvent(uri, content, type) {
  events.emit('change', {
    uri,
    content,
    type, // 'write', 'delete', etc.
    timestamp: Date.now(),
  })
}

// Example usage in adapter operations
async function writeContent(uri, content) {
  try {
    await storage.set(uri, content)

    // Emit change event after successful operation
    emitChangeEvent(uri, content, 'write')
  } catch (error) {
    throw normalizeStorageError(error, 'write', uri)
  }
}

async function deleteContent(uri) {
  try {
    await storage.delete(uri)

    // Emit delete event with null content
    emitChangeEvent(uri, null, 'delete')
  } catch (error) {
    throw normalizeStorageError(error, 'delete', uri)
  }
}

Considerations

  • Emit events only after successful operations to maintain consistency
  • Include all necessary information in the event object
  • Use standard event types across all adapters
  • Consider adding batch change events for performance

URI Mapping Pattern

Pattern Overview

The URI Mapping pattern converts between content URIs and storage-specific paths.

Implementation Example

typescript
// Convert content URI to filesystem path
function mapUriToFilesystemPath(uri, basePath) {
  // Normalize URI to use standard path separators
  const normalizedUri = uri.replace(/\\/g, '/').replace(/^\/+/, '')

  // Resolve against base path
  return path.join(basePath, normalizedUri)
}

// Convert filesystem path back to content URI
function mapFilesystemPathToUri(filePath, basePath) {
  // Get relative path from base
  const relativePath = path.relative(basePath, filePath)

  // Normalize separators for URI format
  return relativePath.replace(/\\/g, '/')
}

// For IndexedDB or localStorage (key-based storage)
function mapUriToStorageKey(uri, prefix = 'content:') {
  // Remove leading/trailing slashes and add prefix
  return prefix + uri.replace(/^\/+|\/+$/g, '')
}

// Convert storage key back to URI
function mapStorageKeyToUri(key, prefix = 'content:') {
  // Remove prefix to get original URI
  if (key.startsWith(prefix)) {
    return key.substring(prefix.length)
  }
  return key
}

Considerations

  • Handle path normalization consistently
  • Account for different path separator conventions
  • Use prefixes to avoid key collisions in shared storage
  • Ensure bidirectional mapping (URI to path and back)

Cache Pattern

Pattern Overview

The Cache Pattern uses a fast adapter (like memory) to cache results from a slower adapter (like HTTP or filesystem).

Implementation Example

typescript
import { createAdapter } from '@lib/content/adapters/base'

export function createCachedAdapter(
  primaryAdapter,
  cacheAdapter,
  options = {}
) {
  const events = createEventEmitter()
  const { ttl = 30000 } = options // Default TTL: 30s

  // Forward primary adapter events
  primaryAdapter.events.on('change', event => {
    events.emit('change', event)

    // Invalidate cache on changes
    if (event.type === 'write' || event.type === 'delete') {
      invalidateCacheEntry(event.uri)
    }
  })

  // Cache invalidation helper
  async function invalidateCacheEntry(uri) {
    try {
      await cacheAdapter.delete(uri)
    } catch (error) {
      // Ignore cache invalidation errors
      console.warn(`Failed to invalidate cache for ${uri}`, error)
    }
  }

  // Add cache metadata
  function addCacheMetadata(content) {
    if (!content.metadata) {
      content.metadata = {}
    }

    content.metadata._cache = {
      timestamp: Date.now(),
      expires: Date.now() + ttl,
    }

    return content
  }

  // Check if cached content is still valid
  function isCacheValid(content) {
    if (!content.metadata?._cache?.expires) {
      return false
    }

    return content.metadata._cache.expires > Date.now()
  }

  return createAdapter({
    read: async uri => {
      // Try cache first
      try {
        const cachedContent = await cacheAdapter.read(uri)

        // Return cache hit if valid
        if (isCacheValid(cachedContent)) {
          return cachedContent
        }
      } catch (error) {
        // Cache miss or error - proceed to primary
      }

      // Cache miss or expired - read from primary
      const content = await primaryAdapter.read(uri)

      // Update cache with fetched content
      try {
        await cacheAdapter.write(uri, addCacheMetadata({ ...content }))
      } catch (error) {
        // Ignore cache write errors
        console.warn(`Failed to update cache for ${uri}`, error)
      }

      return content
    },

    write: async (uri, content) => {
      // Write to primary first
      await primaryAdapter.write(uri, content)

      // Update cache
      try {
        await cacheAdapter.write(uri, addCacheMetadata({ ...content }))
      } catch (error) {
        // Ignore cache write errors
        console.warn(`Failed to update cache for ${uri}`, error)
      }
    },

    delete: async uri => {
      // Delete from primary
      await primaryAdapter.delete(uri)

      // Invalidate cache
      await invalidateCacheEntry(uri)
    },

    list: async pattern => {
      // List operation always goes to primary to ensure accuracy
      return primaryAdapter.list(pattern)
    },

    events,

    dispose: async () => {
      await Promise.all([primaryAdapter.dispose(), cacheAdapter.dispose()])
      events.removeAllListeners()
    },
  })
}

Considerations

  • Add metadata to cached content for expiration tracking
  • Handle cache invalidation when content changes
  • Decide which operations use the cache vs. go directly to primary
  • Define fallback behavior when primary adapter fails

Fallback Pattern

Pattern Overview

The Fallback Pattern provides resilience by trying alternative adapters when the primary adapter fails.

Implementation Example

typescript
import { createAdapter } from '@lib/content/adapters/base'

export function createFallbackAdapter(
  primaryAdapter,
  fallbackAdapter,
  options = {}
) {
  const events = createEventEmitter()

  // Forward all events
  primaryAdapter.events.on('change', event => events.emit('change', event))
  fallbackAdapter.events.on('change', event => events.emit('change', event))

  return createAdapter({
    read: async uri => {
      try {
        // Try primary adapter first
        return await primaryAdapter.read(uri)
      } catch (error) {
        // If primary fails with any error, try fallback
        return await fallbackAdapter.read(uri)
      }
    },

    write: async (uri, content) => {
      try {
        // Try to write to primary first
        await primaryAdapter.write(uri, content)
      } catch (error) {
        // If primary fails, write to fallback
        await fallbackAdapter.write(uri, content)
      }
    },

    delete: async uri => {
      try {
        // Try to delete from primary first
        await primaryAdapter.delete(uri)
      } catch (error) {
        // If primary fails, delete from fallback
        await fallbackAdapter.delete(uri)
      }
    },

    list: async pattern => {
      try {
        // Try primary adapter first
        return await primaryAdapter.list(pattern)
      } catch (error) {
        // If primary fails, use fallback
        return await fallbackAdapter.list(pattern)
      }
    },

    events,

    dispose: async () => {
      await Promise.all([
        primaryAdapter.dispose().catch(() => {}),
        fallbackAdapter.dispose().catch(() => {}),
      ])
      events.removeAllListeners()
    },
  })
}

Considerations

  • Define clear fallback policies for each operation
  • Consider adding retry logic before falling back
  • Implement reconciliation strategies for divergent content
  • Track and report metrics on fallback usage

TTL Pattern

Pattern Overview

The Time-to-Live (TTL) Pattern manages content expiration in memory or cache adapters.

Implementation Example

typescript
import { createAdapter } from '@lib/content/adapters/base'
import { createEventEmitter } from '@lib/utils/events'

export function createTTLAdapter(baseAdapter, options = {}) {
  const { defaultTTL = 3600000 } = options // Default: 1 hour
  const expirations = new Map() // Track expiration times
  const events = createEventEmitter()

  // Forward base adapter events
  baseAdapter.events.on('change', event => events.emit('change', event))

  // Set expiration for a content URI
  function setExpiration(uri, ttl = defaultTTL) {
    const expiresAt = Date.now() + ttl
    expirations.set(uri, expiresAt)

    // Schedule cleanup
    setTimeout(() => {
      if (expirations.get(uri) === expiresAt) {
        // Only delete if expiration hasn't changed
        baseAdapter.delete(uri).catch(() => {})
        expirations.delete(uri)

        // Emit expiration event
        events.emit('change', {
          uri,
          content: null,
          type: 'expire',
          timestamp: Date.now(),
        })
      }
    }, ttl)
  }

  // Check if content is expired
  function isExpired(uri) {
    const expires = expirations.get(uri)
    if (!expires) return false
    return Date.now() > expires
  }

  return createAdapter({
    read: async uri => {
      // Return not found if content is expired
      if (isExpired(uri)) {
        expirations.delete(uri)
        await baseAdapter.delete(uri).catch(() => {})
        throw new ContentNotFoundError(uri)
      }

      return baseAdapter.read(uri)
    },

    write: async (uri, content) => {
      await baseAdapter.write(uri, content)

      // Extract TTL from metadata or use default
      const ttl = content.metadata?.ttl || defaultTTL
      setExpiration(uri, ttl)
    },

    delete: async uri => {
      await baseAdapter.delete(uri)
      expirations.delete(uri)
    },

    list: async pattern => {
      const uris = await baseAdapter.list(pattern)

      // Filter out expired content
      return uris.filter(uri => !isExpired(uri))
    },

    events,

    dispose: async () => {
      expirations.clear()
      await baseAdapter.dispose()
      events.removeAllListeners()
    },
  })
}

Considerations

  • Allow both global and per-content TTL settings
  • Implement efficient expiration tracking and cleanup
  • Consider memory usage implications for large datasets
  • Emit appropriate events when content expires

Resource Management Pattern

Pattern Overview

The Resource Management Pattern ensures proper initialization and cleanup of adapter resources.

Implementation Example

typescript
import { createAdapter } from '@lib/content/adapters/base'

export function createResourceManagedAdapter(options = {}) {
  let initialized = false
  let resources = null

  // Create event emitter
  const events = createEventEmitter()

  // Initialize resources
  async function initializeResources() {
    if (initialized) return

    resources = {
      db: await openDatabase(options.dbName),
      cache: new Map(),
      // Other resources
    }

    initialized = true
  }

  // Close and clean up resources
  async function closeResources() {
    if (!initialized) return

    if (resources.db) {
      await resources.db.close()
    }

    if (resources.cache) {
      resources.cache.clear()
    }

    // Clean up other resources

    resources = null
    initialized = false
  }

  // Ensure resources are initialized before operations
  async function ensureInitialized() {
    if (!initialized) {
      await initializeResources()
    }
    return resources
  }

  return createAdapter({
    read: async uri => {
      const res = await ensureInitialized()

      // Implementation using managed resources
      const data = await res.db.get(uri)
      if (!data) {
        throw new ContentNotFoundError(uri)
      }

      return {
        data: data.content,
        contentType: data.type,
        metadata: data.meta,
      }
    },

    write: async (uri, content) => {
      const res = await ensureInitialized()

      // Implementation using managed resources
      await res.db.put(uri, {
        content: content.data,
        type: content.contentType,
        meta: content.metadata,
      })

      events.emit('change', { uri, content, type: 'write' })
    },

    delete: async uri => {
      const res = await ensureInitialized()

      // Implementation using managed resources
      await res.db.delete(uri)

      events.emit('change', { uri, content: null, type: 'delete' })
    },

    list: async pattern => {
      const res = await ensureInitialized()

      // Implementation using managed resources
      const keys = await res.db.getAllKeys()
      return keys.filter(key => matchesPattern(key, pattern))
    },

    events,

    dispose: async () => {
      await closeResources()
      events.removeAllListeners()
    },
  })
}

Considerations

  • Implement lazy initialization to defer resource allocation
  • Ensure proper cleanup to prevent resource leaks
  • Handle initialization errors gracefully
  • Consider using reference counting for shared resources

Released under the MIT License.