Skip to content

History Adapter

The History adapter provides persistent message storage and event replay capabilities using IndexedDB, perfect for catching up on missed events or debugging application state.

Overview

Store messages in the browser's IndexedDB and replay them on demand. Ideal for:

  • New components catching up on past events
  • Debugging application state
  • Offline-first applications
  • Event sourcing patterns

Quick Start

typescript
import { createPubSub } from '@belyas/pubsub-mfe';
import { createHistoryAdapter } from '@belyas/pubsub-mfe/adapters/history';

const bus = createPubSub({ app: 'my-app' });

// Create and attach history adapter
const history = createHistoryAdapter({
  dbName: 'my-app-history',  // IndexedDB database name
  namespace: 'events',      // Namespace prefix for storage isolation
  maxMessages: 1000,        // Keep last 1000 messages
  ttlSeconds: 86400         // 24 hours retention
});

await history.attach(bus);

// Messages are now automatically stored
bus.publish('cart.item.add', { sku: 'ABC123' });

// Later: Replay history
const pastMessages = await history.getHistory('cart.#');
pastMessages.forEach(msg => console.log(msg));

Configuration

Basic Options

typescript
interface HistoryAdapterConfig {
  dbName: string;           // IndexedDB database name
  namespace: string;        // Storage namespace/table
  maxMessages?: number;     // Max messages to keep (default: 1000)
  ttlSeconds?: number;      // Message TTL in seconds (default: 3600)
  gcIntervalMs?: number;    // Garbage collection interval (default: 60000)
  debug?: boolean;          // Enable debug logging
  onError?: (error: Error) => void;  // Error callback
}

Example Configuration

typescript
const history = createHistoryAdapter({
  dbName: 'my-app-v1',
  namespace: 'events',
  maxMessages: 5000,        // Keep 5000 messages
  ttlSeconds: 172800,       // 2 days retention
  gcIntervalMs: 300000,     // GC every 5 minutes
  debug: true,
  onError: (error) => {
    console.error('History adapter error:', error);
    Sentry.captureException(error);
  }
});

Usage Patterns

Catch-Up on Component Mount

typescript
// Component loads and replays missed cart events
async function initCartComponent() {
  const bus = createPubSub({ app: 'shop' });
  const history = createHistoryAdapter({
    dbName: 'shop-history',
    namespace: 'events'
  });
  
  await history.attach(bus);
  
  // Replay cart events from the last hour
  const oneHourAgo = Date.now() - 3600000;
  const cartHistory = await history.getHistory('cart.#', {
    fromTime: oneHourAgo
  });
  
  // Reconstruct cart state
  const cartState = reconstructCartState(cartHistory);
  updateUI(cartState);
  
  // Subscribe to future events
  bus.subscribe('cart.#', (msg) => {
    updateCartState(msg);
  });
}

Event Sourcing

typescript
// Replay all order events to rebuild state
async function rebuildOrderState(orderId: string) {
  const orderEvents = await history.getHistory(`order.${orderId}.#`);
  
  let orderState = createEmptyOrder(orderId);
  
  for (const event of orderEvents) {
    switch (event.topic) {
      case `order.${orderId}.created`:
        orderState = applyCreatedEvent(orderState, event.payload);
        break;
      case `order.${orderId}.item.added`:
        orderState = applyItemAdded(orderState, event.payload);
        break;
      case `order.${orderId}.status.updated`:
        orderState = applyStatusUpdate(orderState, event.payload);
        break;
    }
  }
  
  return orderState;
}

Debugging Timeline

typescript
// View all events with time and limit
async function debugEventTimeline(startTime: number, limit: number) {
  const events = await history.getHistory('#', {
    fromTime: startTime,
    limit
  });
  
  console.log(`Found ${events.length} events`);
  events.forEach(event => {
    console.log(`[${new Date(event.timestamp).toISOString()}] ${event.topic}`, event.payload);
  });
}

// Usage
await debugEventTimeline(
  Date.now() - 300000,  // Last 5 minutes
  50
);

API Methods

attach(bus)

Attaches the history adapter to a PubSub bus.

typescript
await history.attach(bus);

detach()

Detaches the adapter and stops recording.

typescript
await history.detach();

getHistory(pattern, options?: HistoryQueryOptions)

Retrieves messages matching a topic pattern.

typescript
interface HistoryQueryOptions {
  fromTime?: number; // Return messages with timestamp >= fromTime.
  limit?: number; // Maximum number of messages to return.
}

const messages = await history.getHistory('cart.#', {
  fromTime: Date.now() - 3600000,  // Last hour
  limit: 100                     // Max 100 messages
});

clearHistory()

Clears stored messages.

typescript
// Clear all messages for this namespace
await history.clearHistory();

getStats()

Returns storage statistics.

typescript
const stats = await history.getStats();

console.log({
  messagesPersisted: stats.messagesPersisted,
  messagesRetrieved: stats.messagesRetrieved,
  messagesGarbageCollected: stats.messagesGarbageCollected,
  gcCyclesCompleted: stats.gcCyclesCompleted,
  estimatedStorageCount: stats.estimatedStorageCount,
  duplicatesSkipped: stats.duplicatesSkipped,  
  lastGcTimestamp: stats.lastGcTimestamp,
  attached: stats.attached,
  namespace: stats.namespace,
});

forceGc()

Manually trigger garbage collection.

typescript
await history.forceGc();

Garbage Collection

The adapter automatically cleans up old messages based on:

TTL-Based Cleanup

Messages older than ttlSeconds are automatically removed:

typescript
const history = createHistoryAdapter({
  dbName: 'my-app',
  namespace: 'events',
  ttlSeconds: 86400  // Delete messages older than 24 hours
});

Capacity-Based Cleanup

Oldest messages are removed when maxMessages limit is exceeded:

typescript
const history = createHistoryAdapter({
  dbName: 'my-app',
  namespace: 'events',
  maxMessages: 1000  // Keep only last 1000 messages
});

GC Schedule

Garbage collection runs automatically:

typescript
const history = createHistoryAdapter({
  dbName: 'my-app',
  namespace: 'events',
  gcIntervalMs: 60000  // Run GC every minute
});

Performance

Indexing

Messages are indexed by:

  • Topic (for pattern matching)
  • Timestamp (for time-range queries)
  • Namespace (for isolation)

Query Optimization

typescript
// ✅ Fast: Specific topic with time range
await history.getHistory('cart.item.add', {
  timeFrom: Date.now() - 3600000,
  limit: 50
});

// ⚠️ Slower: Broad pattern without limits
await history.getHistory('cart.#');

// ❌ Slow: Global pattern
await history.getHistory('#');

Storage Limits

IndexedDB has browser-specific limits:

  • Chrome: ~80% of available disk space
  • Firefox: ~50% of available disk space
  • Safari: ~1GB per origin

Best Practices

1. Set Appropriate Retention

typescript
// Short-lived events (UI state)
const uiHistory = createHistoryAdapter({
  ttlSeconds: 3600,      // 1 hour
  maxMessages: 500
});

// Long-lived events (business data)
const businessHistory = createHistoryAdapter({
  ttlSeconds: 2592000,   // 30 days
  maxMessages: 10000
});

2. Use Namespaces

typescript
// Separate history per feature
const cartHistory = createHistoryAdapter({
  dbName: 'my-app',
  namespace: 'cart'
});

const userHistory = createHistoryAdapter({
  dbName: 'my-app',
  namespace: 'user'
});

3. Limit Query Results

typescript
// Always use limits for large datasets
const recentEvents = await history.getHistory('cart.#', {
  timeFrom: Date.now() - 86400000,
  limit: 100  // Safety limit
});

4. Monitor Storage

typescript
setInterval(async () => {
  const stats = await history.getStats();
  
  if (stats.estimatedStorageCount > 5000) {
    console.warn('History storage getting large');
    await history.forceGc();
  }
}, 300000);  // Check every 5 minutes

Error Handling

typescript
const history = createHistoryAdapter({
  dbName: 'my-app',
  namespace: 'events',
  onError: (error) => {
    if (error.name === 'QuotaExceededError') {
      console.error('Storage quota exceeded');
      // Trigger aggressive cleanup
      history.clearHistory();
    } else {
      console.error('History error:', error);
      Sentry.captureException(error);
    }
  }
});

Browser Support

IndexedDB is supported in all modern browsers:

  • Chrome: ✅ Full support
  • Firefox: ✅ Full support
  • Safari: ✅ Full support (iOS 10+)
  • Edge: ✅ Full support

Next Steps

🗄️ Storage Considerations

IndexedDB is persistent but can be cleared by the browser under storage pressure. For critical data, implement server-side backup.

Released under the Apache-2.0 License.