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
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)
Related Patterns
- Capability Detection Pattern - Detecting available storage features
- Adapter Composition Pattern - Combining multiple adapters
Adapter Interface Implementation Pattern
Pattern Overview
This pattern ensures consistent implementation of the adapter interface across different storage backends.
Implementation Example
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
Related Patterns
- Error Normalization Pattern - Handling adapter-specific errors
- Event Emission Pattern - Notifying about content changes
Adapter Composition Pattern
Pattern Overview
The Adapter Composition pattern combines multiple adapters to create enhanced functionality.
Implementation Example
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
Related Patterns
- Fallback Pattern - Trying alternative adapters when primary fails
- Cache Pattern - Using a fast adapter as a cache for a slower one
Error Normalization Pattern
Pattern Overview
The Error Normalization pattern converts storage-specific errors into standard content system errors.
Implementation Example
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
Related Patterns
- Error Recovery Pattern - Strategies for recovering from errors
- Error Context Pattern - Adding contextual information to errors
Event Emission Pattern
Pattern Overview
The Event Emission pattern standardizes how adapters notify about content changes.
Implementation Example
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
Related Patterns
- Observable Pattern - Creating observable streams from change events
- Event Debouncing Pattern - Controlling event frequency
URI Mapping Pattern
Pattern Overview
The URI Mapping pattern converts between content URIs and storage-specific paths.
Implementation Example
// 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)
Related Patterns
- Content Addressing Pattern - Consistent addressing across adapters
- URI Normalization Pattern - Standardizing URI format
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
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
Related Patterns
- Adapter Composition Pattern - Combining multiple adapters
- TTL Pattern - Time-based cache expiration
Fallback Pattern
Pattern Overview
The Fallback Pattern provides resilience by trying alternative adapters when the primary adapter fails.
Implementation Example
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
Related Patterns
- Retry Pattern - Automatically retrying failed operations
- Circuit Breaker Pattern - Preventing cascading failures
TTL Pattern
Pattern Overview
The Time-to-Live (TTL) Pattern manages content expiration in memory or cache adapters.
Implementation Example
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
Related Patterns
- Cache Pattern - Using temporary storage for performance
- Resource Management Pattern - Cleaning up unused resources
Resource Management Pattern
Pattern Overview
The Resource Management Pattern ensures proper initialization and cleanup of adapter resources.
Implementation Example
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
Related Patterns
- Lazy Loading Pattern - Deferring resource initialization
- Lifecycle Management Pattern - Managing component lifecycles