Caching Strategies

dcupl provides built-in caching mechanisms and strategies to optimize performance. Learn how to leverage automatic query caching, manage cache invalidation, and implement custom caching patterns including IndexedDB, localStorage, and offline-first approaches.

Understanding dcupl Caching

flowchart LR
  Q[Query] --> C{Cache Hit?}
  C -->|Yes| R[Return Cached]
  C -->|No| E[Execute Query]
  E --> S[Store in Cache]
  S --> R2[Return Results]
  U[Data Update] --> I[Invalidate Cache]

dcupl automatically caches query results at the list level. When you execute a query, results are cached until:

  • The query changes
  • Data is updated
  • Manual cache invalidation

Key concepts:

  • Automatic caching - Query results cached by default
  • Cache invalidation - Triggered by data updates
  • Manual control - Override automatic behavior
  • List-level caching - Each list maintains its own cache

Automatic Query Caching

How It Works

const productList = dcupl.lists.create({ modelKey: 'Product' });

// First execution - computes results
productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});
const results1 = productList.catalog.query.items(); // Query executed

// Second execution - returns cached results
const results2 = productList.catalog.query.items(); // From cache (instant)

// Same reference!
console.log(results1 === results2); // true

Performance: Cached queries return instantly (< 1ms).

Cache Scope

Each list maintains its own query cache:

const list1 = dcupl.lists.create({ modelKey: 'Product' });
const list2 = dcupl.lists.create({ modelKey: 'Product' });

list1.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});

list2.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Clothing',
});

// Each list has independent cache
const electronics = list1.catalog.query.items(); // Cached for list1
const clothing = list2.catalog.query.items(); // Cached for list2

Cache Invalidation

Automatic Invalidation

Cache is automatically invalidated when data changes:

const productList = dcupl.lists.create({ modelKey: 'Product' });

productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});

const results1 = productList.catalog.query.items(); // Cached

// Update data - cache invalidated
dcupl.data.update([{ key: 'p1', category: 'Clothing' }], { model: 'Product' });

const results2 = productList.catalog.query.items(); // Re-computed
console.log(results1 === results2); // false

Manual Cache Invalidation

Force cache refresh:

const productList = dcupl.lists.create({ modelKey: 'Product' });

// Execute query
productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});
const results1 = productList.catalog.query.items(); // Cached

// Manually invalidate
productList.catalog.query.clear();

// Re-apply query
productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});
const results2 = productList.catalog.query.items(); // Re-computed

Partial Cache Invalidation

With partial updates, only affected queries are invalidated:

Query on category
productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});
const results1 = productList.catalog.query.items(); // Cached

// Update unrelated property - cache still valid
dcupl.data.update([{ key: 'p1', stock: 100 }], { model: 'Product' }); // Not queried on

const results2 = productList.catalog.query.items(); // Still cached!
console.log(results1 === results2); // true

List Update and Cache

list.update()

The list.update() method triggers cache refresh:

const productList = dcupl.lists.create({ modelKey: 'Product' });

productList.catalog.query.addCondition({
  attribute: 'inStock',
  operator: 'eq',
  value: true,
});

const results1 = productList.catalog.query.items();

// Update data and refresh list
dcupl.data.update([{ key: 'p1', inStock: false }], { model: 'Product' });

// Trigger list update
await productList.update();

const results2 = productList.catalog.query.items(); // Fresh results
console.log(results1.length !== results2.length); // true

autoUpdate Option

Enable automatic list updates:

const productList = dcupl.lists.create({
  modelKey: 'Product',
  autoUpdate: true, // Auto-refresh on data changes
});

productList.catalog.query.addCondition({
  attribute: 'category',
  operator: 'eq',
  value: 'Electronics',
});

const results1 = productList.catalog.query.items();

// Update data
dcupl.data.update([{ key: 'p1', category: 'Clothing' }], { model: 'Product' });

// List automatically updated!
const results2 = productList.catalog.query.items(); // Fresh data

Note: autoUpdate: true can impact performance for frequent updates.

Facet Caching

Facets are also cached automatically:

const productList = dcupl.lists.create({ modelKey: 'Product' });

// First call - computed
const facets1 = productList.catalog.fn.facets({ attribute: 'category' });

// Second call - cached
const facets2 = productList.catalog.fn.facets({ attribute: 'category' });

console.log(facets1 === facets2); // true

Facet Cache Invalidation

const productList = dcupl.lists.create({ modelKey: 'Product' });

const facets1 = productList.catalog.fn.facets({ attribute: 'category' });

// Update data - facet cache invalidated
dcupl.data.update([{ key: 'p1', category: 'NewCategory' }], { model: 'Product' });

const facets2 = productList.catalog.fn.facets({ attribute: 'category' });
console.log(facets1 === facets2); // false (re-computed)

Aggregation Caching

Aggregations are cached like queries:

const orderList = dcupl.lists.create({ modelKey: 'Order' });

orderList.catalog.query.addCondition({
  attribute: 'status',
  operator: 'eq',
  value: 'completed',
});

// First call - computed
const stats1 = orderList.catalog.fn.aggregate({
  attribute: 'total',
  types: ['sum', 'avg', 'count'],
});

// Second call - cached
const stats2 = orderList.catalog.fn.aggregate({
  attribute: 'total',
  types: ['sum', 'avg', 'count'],
});

console.log(stats1 === stats2); // true

IndexedDB Integration

IndexedDB provides persistent storage for large datasets with asynchronous access. Use it for caching dcupl data across browser sessions.

Basic IndexedDB Cache

indexeddb-cache.ts
class IndexedDBCache {
  private dbName = 'dcupl-cache';
  private storeName = 'data';
  private db: IDBDatabase | null = null;

  async open(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName, { keyPath: 'key' });
        }
      };
    });
  }

  async set<T>(key: string, data: T, ttl?: number): Promise<void> {
    if (!this.db) await this.open();

    const entry = {
      key,
      data,
      timestamp: Date.now(),
      expiry: ttl ? Date.now() + ttl : null,
    };

    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(this.storeName, 'readwrite');
      const store = tx.objectStore(this.storeName);
      const request = store.put(entry);

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async get<T>(key: string): Promise<T | null> {
    if (!this.db) await this.open();

    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(this.storeName, 'readonly');
      const store = tx.objectStore(this.storeName);
      const request = store.get(key);

      request.onsuccess = () => {
        const entry = request.result;
        if (!entry) {
          resolve(null);
          return;
        }

        // Check expiry
        if (entry.expiry && Date.now() > entry.expiry) {
          this.delete(key);
          resolve(null);
          return;
        }

        resolve(entry.data as T);
      };

      request.onerror = () => reject(request.error);
    });
  }

  async delete(key: string): Promise<void> {
    if (!this.db) await this.open();

    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(this.storeName, 'readwrite');
      const store = tx.objectStore(this.storeName);
      const request = store.delete(key);

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async clear(): Promise<void> {
    if (!this.db) await this.open();

    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(this.storeName, 'readwrite');
      const store = tx.objectStore(this.storeName);
      const request = store.clear();

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }
}

Using IndexedDB with dcupl

dcupl-with-indexeddb.ts
const idbCache = new IndexedDBCache();

async function initDcuplWithCache() {
  const dcupl = new Dcupl();

  // Define model
  dcupl.models.set({
    key: 'Product',
    properties: [
      { key: 'name', type: 'string' },
      { key: 'category', type: 'string' },
      { key: 'price', type: 'float' },
    ],
  });

  // Try to load from IndexedDB first
  const cachedProducts = await idbCache.get<Product[]>('products');

  if (cachedProducts) {
    console.log('Loading from IndexedDB cache');
    dcupl.data.set(cachedProducts, { model: 'Product' });
  } else {
    console.log('Fetching fresh data');
    const products = await fetch('/api/products').then((r) => r.json());
    dcupl.data.set(products, { model: 'Product' });

    // Cache for 1 hour
    await idbCache.set('products', products, 60 * 60 * 1000);
  }

  await dcupl.init();
  return dcupl;
}

IndexedDB with Versioning

versioned-cache.ts
class VersionedIndexedDBCache {
  private cache: IndexedDBCache;
  private versionKey = 'cache-version';

  constructor() {
    this.cache = new IndexedDBCache();
  }

  async getData<T>(key: string, currentVersion: string): Promise<T | null> {
    const versionInfo = await this.cache.get<{ version: string }>(this.versionKey);

    // Version mismatch - invalidate cache
    if (!versionInfo || versionInfo.version !== currentVersion) {
      await this.cache.clear();
      await this.cache.set(this.versionKey, { version: currentVersion });
      return null;
    }

    return this.cache.get(key);
  }

  async setData<T>(key: string, data: T, version: string, ttl?: number): Promise<void> {
    await this.cache.set(this.versionKey, { version });
    await this.cache.set(key, data, ttl);
  }
}

// Usage with dcupl
const versionedCache = new VersionedIndexedDBCache();
const DATA_VERSION = '1.2.0'; // Bump when data schema changes

async function loadProducts() {
  const cached = await versionedCache.getData('products', DATA_VERSION);

  if (cached) {
    return cached;
  }

  const products = await fetch('/api/products').then((r) => r.json());
  await versionedCache.setData('products', products, DATA_VERSION, 3600000);
  return products;
}

localStorage Strategies

localStorage provides simple synchronous storage for smaller datasets (< 5MB).

Basic localStorage Cache

localstorage-cache.ts
class LocalStorageCache {
  private prefix = 'dcupl:';

  set<T>(key: string, data: T, ttl?: number): void {
    const entry = {
      data,
      timestamp: Date.now(),
      expiry: ttl ? Date.now() + ttl : null,
    };

    try {
      localStorage.setItem(this.prefix + key, JSON.stringify(entry));
    } catch (e) {
      // Handle quota exceeded
      if (e instanceof DOMException && e.name === 'QuotaExceededError') {
        this.clearExpired();
        localStorage.setItem(this.prefix + key, JSON.stringify(entry));
      }
    }
  }

  get<T>(key: string): T | null {
    const raw = localStorage.getItem(this.prefix + key);
    if (!raw) return null;

    try {
      const entry = JSON.parse(raw);

      // Check expiry
      if (entry.expiry && Date.now() > entry.expiry) {
        this.delete(key);
        return null;
      }

      return entry.data as T;
    } catch {
      return null;
    }
  }

  delete(key: string): void {
    localStorage.removeItem(this.prefix + key);
  }

  clearExpired(): void {
    const now = Date.now();
    const keysToRemove: string[] = [];

    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (!key?.startsWith(this.prefix)) continue;

      try {
        const entry = JSON.parse(localStorage.getItem(key) || '');
        if (entry.expiry && now > entry.expiry) {
          keysToRemove.push(key);
        }
      } catch {
        keysToRemove.push(key);
      }
    }

    keysToRemove.forEach((key) => localStorage.removeItem(key));
  }

  clear(): void {
    const keysToRemove: string[] = [];

    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key?.startsWith(this.prefix)) {
        keysToRemove.push(key);
      }
    }

    keysToRemove.forEach((key) => localStorage.removeItem(key));
  }
}

localStorage for User Preferences

preferences-cache.ts
const lsCache = new LocalStorageCache();

// Cache user query preferences
function saveQueryPreferences(userId: string, preferences: QueryPreferences) {
  lsCache.set(`prefs:${userId}`, preferences);
}

function loadQueryPreferences(userId: string): QueryPreferences | null {
  return lsCache.get<QueryPreferences>(`prefs:${userId}`);
}

// Usage
const prefs = loadQueryPreferences('user-123') || {
  sortBy: 'name',
  sortOrder: 'asc',
  pageSize: 20,
  filters: {},
};

const productList = dcupl.lists.create({
  modelKey: 'Product',
  pagination: { pageSize: prefs.pageSize },
});

if (prefs.filters.category) {
  productList.catalog.query.addCondition({
    attribute: 'category',
    operator: 'eq',
    value: prefs.filters.category,
  });
}

When to Use localStorage vs IndexedDB

Feature localStorage IndexedDB
Storage Limit ~5MB ~50MB+ (varies)
API Synchronous Asynchronous
Data Types Strings only Any structured data
Best For Small configs Large datasets
Query Support None Indexes available
Transactions No Yes
Choose the right storage
// localStorage: User preferences, small configs
localStorage.setItem('theme', 'dark');
localStorage.setItem('language', 'en');

// IndexedDB: Product catalogs, large datasets
await idbCache.set('products', largeProductArray);
await idbCache.set('categories', categoryTree);

Cache Invalidation Patterns

Time-Based Invalidation (TTL)

ttl-cache.ts
class TTLCache<T> {
  private cache: Map<string, { data: T; expiry: number }> = new Map();

  set(key: string, data: T, ttlMs: number): void {
    this.cache.set(key, {
      data,
      expiry: Date.now() + ttlMs,
    });
  }

  get(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;

    if (Date.now() > entry.expiry) {
      this.cache.delete(key);
      return null;
    }

    return entry.data;
  }

  invalidate(key: string): void {
    this.cache.delete(key);
  }

  invalidatePattern(pattern: RegExp): void {
    for (const key of this.cache.keys()) {
      if (pattern.test(key)) {
        this.cache.delete(key);
      }
    }
  }
}

// Usage
const queryCache = new TTLCache<Product[]>();

function getCachedProducts(category: string): Product[] | null {
  const cacheKey = `products:${category}`;
  return queryCache.get(cacheKey);
}

function cacheProducts(category: string, products: Product[]): void {
  const cacheKey = `products:${category}`;
  queryCache.set(cacheKey, products, 5 * 60 * 1000); // 5 minutes
}

// Invalidate all product caches when data updates
dcupl.on('dataChange', (event) => {
  if (event.model === 'Product') {
    queryCache.invalidatePattern(/^products:/);
  }
});

Event-Based Invalidation

event-invalidation.ts
class EventDrivenCache {
  private cache: Map<string, unknown> = new Map();
  private dependencies: Map<string, Set<string>> = new Map();

  set<T>(key: string, data: T, dependsOn: string[] = []): void {
    this.cache.set(key, data);

    // Track dependencies
    dependsOn.forEach((dep) => {
      if (!this.dependencies.has(dep)) {
        this.dependencies.set(dep, new Set());
      }
      this.dependencies.get(dep)!.add(key);
    });
  }

  get<T>(key: string): T | null {
    return (this.cache.get(key) as T) || null;
  }

  invalidate(dependency: string): void {
    const affectedKeys = this.dependencies.get(dependency);
    if (!affectedKeys) return;

    affectedKeys.forEach((key) => {
      this.cache.delete(key);
    });

    this.dependencies.delete(dependency);
  }
}

// Usage with dcupl
const eventCache = new EventDrivenCache();

// Cache query results with model dependency
function cacheQueryResult<T>(queryId: string, results: T[], model: string) {
  eventCache.set(queryId, results, [model]);
}

// Invalidate when model data changes
dcupl.on('dataChange', (event) => {
  eventCache.invalidate(event.model);
});

Version-Based Invalidation

version-cache.ts
class VersionedCache {
  private cache: Map<string, { data: unknown; version: number }> = new Map();
  private versions: Map<string, number> = new Map();

  getVersion(namespace: string): number {
    return this.versions.get(namespace) || 0;
  }

  bumpVersion(namespace: string): number {
    const current = this.getVersion(namespace);
    const newVersion = current + 1;
    this.versions.set(namespace, newVersion);
    return newVersion;
  }

  set<T>(key: string, data: T, namespace: string): void {
    this.cache.set(key, {
      data,
      version: this.getVersion(namespace),
    });
  }

  get<T>(key: string, namespace: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;

    // Check if version is current
    if (entry.version !== this.getVersion(namespace)) {
      this.cache.delete(key);
      return null;
    }

    return entry.data as T;
  }
}

// Usage
const versionCache = new VersionedCache();

// Cache products
versionCache.set('all-products', products, 'Product');

// On data update, bump version (invalidates all Product caches)
dcupl.on('dataChange', (event) => {
  versionCache.bumpVersion(event.model);
});

// Get returns null if version changed
const cached = versionCache.get('all-products', 'Product');

Stale-While-Revalidate Pattern

Return cached data immediately while fetching fresh data in the background.

sequenceDiagram
  participant U as User
  participant C as Cache
  participant A as API

  U->>C: Request data
  C->>U: Return stale data (instant)
  C->>A: Fetch fresh data (background)
  A->>C: Fresh data
  C->>C: Update cache
  Note over C: Next request gets fresh data

Implementation

stale-while-revalidate.ts
class SWRCache<T> {
  private cache: Map<string, { data: T; timestamp: number }> = new Map();
  private pending: Map<string, Promise<T>> = new Map();
  private ttl: number;

  constructor(ttlMs: number = 60000) {
    this.ttl = ttlMs;
  }

  async get(
    key: string,
    fetcher: () => Promise<T>,
    options?: { forceRefresh?: boolean }
  ): Promise<T> {
    const cached = this.cache.get(key);
    const now = Date.now();

    // Fresh cache - return immediately
    if (cached && !options?.forceRefresh) {
      const isStale = now - cached.timestamp > this.ttl;

      if (!isStale) {
        return cached.data;
      }

      // Stale - return cached, revalidate in background
      this.revalidate(key, fetcher);
      return cached.data;
    }

    // No cache - fetch and wait
    return this.fetchAndCache(key, fetcher);
  }

  private async fetchAndCache(key: string, fetcher: () => Promise<T>): Promise<T> {
    // Deduplicate concurrent requests
    const pending = this.pending.get(key);
    if (pending) return pending;

    const promise = fetcher().then((data) => {
      this.cache.set(key, { data, timestamp: Date.now() });
      this.pending.delete(key);
      return data;
    });

    this.pending.set(key, promise);
    return promise;
  }

  private async revalidate(key: string, fetcher: () => Promise<T>): Promise<void> {
    // Skip if already revalidating
    if (this.pending.has(key)) return;

    try {
      await this.fetchAndCache(key, fetcher);
    } catch (error) {
      console.error('Revalidation failed:', error);
      // Keep stale data on failure
    }
  }

  invalidate(key: string): void {
    this.cache.delete(key);
  }
}

Using SWR with dcupl

dcupl-swr.ts
const swrCache = new SWRCache<Product[]>(5 * 60 * 1000); // 5 min TTL

class ProductCatalog {
  private dcupl: Dcupl;
  private list: DcuplList;

  constructor() {
    this.dcupl = new Dcupl();
    this.setupModel();
  }

  private setupModel() {
    this.dcupl.models.set({
      key: 'Product',
      properties: [
        { key: 'name', type: 'string' },
        { key: 'category', type: 'string' },
        { key: 'price', type: 'float' },
      ],
    });
  }

  async getProducts(category: string): Promise<Product[]> {
    const cacheKey = `products:${category}`;

    return swrCache.get(cacheKey, async () => {
      // Fetch fresh data
      const products = await fetch(`/api/products?category=${category}`).then((r) => r.json());

      // Update dcupl
      this.dcupl.data.set(products, { model: 'Product' });
      await this.dcupl.init();

      // Create/update list
      if (!this.list) {
        this.list = this.dcupl.lists.create({ modelKey: 'Product' });
      }

      this.list.catalog.query.clear();
      this.list.catalog.query.addCondition({
        attribute: 'category',
        operator: 'eq',
        value: category,
      });

      return this.list.catalog.query.items();
    });
  }

  invalidateCategory(category: string): void {
    swrCache.invalidate(`products:${category}`);
  }
}

// Usage
const catalog = new ProductCatalog();

// First call - fetches from API
const electronics = await catalog.getProducts('Electronics');

// Subsequent calls within TTL - instant from cache
const electronicsAgain = await catalog.getProducts('Electronics');

// After TTL - returns stale, refreshes in background
const electronicsLater = await catalog.getProducts('Electronics');

Offline-First Patterns

Build applications that work offline and sync when connectivity returns.

flowchart TB
  subgraph Online
    A[Fetch API] --> B[Update Cache]
    B --> C[Update dcupl]
  end

  subgraph Offline
    D[Read Cache] --> E[Load dcupl]
    E --> F[Queue Changes]
  end

  G{Online?}
  G -->|Yes| Online
  G -->|No| Offline

  F -->|Reconnect| H[Sync Queue]
  H --> A

Offline-First Service

offline-first-service.ts
class OfflineFirstService {
  private dcupl: Dcupl;
  private idbCache: IndexedDBCache;
  private syncQueue: Array<{ action: string; data: unknown }> = [];
  private isOnline: boolean = navigator.onLine;

  constructor() {
    this.dcupl = new Dcupl();
    this.idbCache = new IndexedDBCache();
    this.setupNetworkListeners();
  }

  private setupNetworkListeners() {
    window.addEventListener('online', () => {
      this.isOnline = true;
      this.syncPendingChanges();
    });

    window.addEventListener('offline', () => {
      this.isOnline = false;
    });
  }

  async initialize(): Promise<void> {
    // Setup model
    this.dcupl.models.set({
      key: 'Product',
      properties: [
        { key: 'name', type: 'string' },
        { key: 'category', type: 'string' },
        { key: 'price', type: 'float' },
        { key: 'updatedAt', type: 'date' },
      ],
    });

    // Load from cache first (instant)
    const cachedData = await this.idbCache.get<Product[]>('products');
    if (cachedData) {
      this.dcupl.data.set(cachedData, { model: 'Product' });
      await this.dcupl.init();
    }

    // Load sync queue
    const queue = await this.idbCache.get<Array<{ action: string; data: unknown }>>('sync-queue');
    if (queue) {
      this.syncQueue = queue;
    }

    // Fetch fresh data if online
    if (this.isOnline) {
      await this.refreshFromServer();
    }
  }

  private async refreshFromServer(): Promise<void> {
    try {
      const products = await fetch('/api/products').then((r) => r.json());

      // Update cache
      await this.idbCache.set('products', products);

      // Update dcupl
      this.dcupl.data.set(products, { model: 'Product' });
      await this.dcupl.init();
    } catch (error) {
      console.warn('Failed to refresh from server:', error);
    }
  }

  async updateProduct(key: string, changes: Partial<Product>): Promise<void> {
    // Update local dcupl immediately
    this.dcupl.data.update(
      [{ key, ...changes, updatedAt: new Date().toISOString() }],
      { model: 'Product' }
    );

    // Update local cache
    const products = await this.idbCache.get<Product[]>('products') || [];
    const index = products.findIndex((p) => p.key === key);
    if (index >= 0) {
      products[index] = { ...products[index], ...changes };
      await this.idbCache.set('products', products);
    }

    if (this.isOnline) {
      // Sync immediately
      await this.syncToServer({ action: 'update', data: { key, ...changes } });
    } else {
      // Queue for later sync
      this.queueChange({ action: 'update', data: { key, ...changes } });
    }
  }

  private queueChange(change: { action: string; data: unknown }): void {
    this.syncQueue.push(change);
    this.idbCache.set('sync-queue', this.syncQueue);
  }

  private async syncPendingChanges(): Promise<void> {
    if (this.syncQueue.length === 0) return;

    const queue = [...this.syncQueue];
    this.syncQueue = [];

    for (const change of queue) {
      try {
        await this.syncToServer(change);
      } catch (error) {
        // Re-queue failed changes
        this.queueChange(change);
      }
    }

    await this.idbCache.set('sync-queue', this.syncQueue);

    // Refresh from server to get any remote changes
    await this.refreshFromServer();
  }

  private async syncToServer(change: { action: string; data: unknown }): Promise<void> {
    await fetch('/api/products/sync', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(change),
    });
  }

  getList(): DcuplList {
    return this.dcupl.lists.create({ modelKey: 'Product' });
  }
}

Service Worker Integration

sw-cache.ts
// In service worker (sw.js)
const CACHE_NAME = 'dcupl-data-v1';
const DATA_URLS = ['/api/products', '/api/categories'];

self.addEventListener('fetch', (event: FetchEvent) => {
  const url = new URL(event.request.url);

  if (DATA_URLS.some((path) => url.pathname.startsWith(path))) {
    event.respondWith(staleWhileRevalidate(event.request));
  }
});

async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(CACHE_NAME);
  const cachedResponse = await cache.match(request);

  const fetchPromise = fetch(request)
    .then((response) => {
      if (response.ok) {
        cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => cachedResponse!);

  return cachedResponse || fetchPromise;
}

// In main app
async function loadWithServiceWorker() {
  // This will use service worker cache automatically
  const products = await fetch('/api/products').then((r) => r.json());

  dcupl.data.set(products, { model: 'Product' });
  await dcupl.init();
}

Cache Warming Strategies

Pre-populate caches to improve initial load performance.

Eager Cache Warming

eager-warming.ts
class CacheWarmer {
  private dcupl: Dcupl;
  private idbCache: IndexedDBCache;

  constructor(dcupl: Dcupl) {
    this.dcupl = dcupl;
    this.idbCache = new IndexedDBCache();
  }

  async warmOnIdle(): Promise<void> {
    // Use requestIdleCallback for non-blocking warming
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => this.warmCaches(), { timeout: 5000 });
    } else {
      // Fallback with setTimeout
      setTimeout(() => this.warmCaches(), 1000);
    }
  }

  private async warmCaches(): Promise<void> {
    const commonCategories = ['Electronics', 'Clothing', 'Books', 'Home'];
    const productList = this.dcupl.lists.create({ modelKey: 'Product' });

    for (const category of commonCategories) {
      productList.catalog.query.clear();
      productList.catalog.query.addCondition({
        attribute: 'category',
        operator: 'eq',
        value: category,
      });

      // Execute query to warm dcupl's internal cache
      const results = productList.catalog.query.items();

      // Also warm persistent cache
      await this.idbCache.set(`category:${category}`, results, 3600000);
    }

    // Warm facets
    const facets = productList.catalog.fn.facets({ attribute: 'category' });
    await this.idbCache.set('facets:category', facets, 3600000);

    productList.destroy();
  }
}

// Usage
const dcupl = new Dcupl();
await dcupl.init();

const warmer = new CacheWarmer(dcupl);
warmer.warmOnIdle();

Predictive Cache Warming

predictive-warming.ts
class PredictiveCacheWarmer {
  private accessHistory: Map<string, number> = new Map();
  private dcupl: Dcupl;

  constructor(dcupl: Dcupl) {
    this.dcupl = dcupl;
    this.loadHistory();
  }

  private loadHistory(): void {
    const stored = localStorage.getItem('access-history');
    if (stored) {
      const entries = JSON.parse(stored);
      this.accessHistory = new Map(entries);
    }
  }

  private saveHistory(): void {
    const entries = Array.from(this.accessHistory.entries());
    localStorage.setItem('access-history', JSON.stringify(entries));
  }

  trackAccess(category: string): void {
    const count = this.accessHistory.get(category) || 0;
    this.accessHistory.set(category, count + 1);
    this.saveHistory();
  }

  getMostAccessed(limit: number = 5): string[] {
    return Array.from(this.accessHistory.entries())
      .sort((a, b) => b[1] - a[1])
      .slice(0, limit)
      .map(([category]) => category);
  }

  async warmPredictedCategories(): Promise<void> {
    const topCategories = this.getMostAccessed(5);
    const productList = this.dcupl.lists.create({ modelKey: 'Product' });

    for (const category of topCategories) {
      productList.catalog.query.clear();
      productList.catalog.query.addCondition({
        attribute: 'category',
        operator: 'eq',
        value: category,
      });

      // Warm cache
      productList.catalog.query.items();
    }

    productList.destroy();
  }
}

// Usage
const predictor = new PredictiveCacheWarmer(dcupl);

// Track when user accesses a category
function onCategorySelect(category: string) {
  predictor.trackAccess(category);
  // ... load products
}

// Warm predicted caches on app start
predictor.warmPredictedCategories();

Background Data Refresh

background-refresh.ts
class BackgroundRefresher {
  private dcupl: Dcupl;
  private refreshInterval: number;
  private intervalId: number | null = null;

  constructor(dcupl: Dcupl, refreshIntervalMs: number = 5 * 60 * 1000) {
    this.dcupl = dcupl;
    this.refreshInterval = refreshIntervalMs;
  }

  start(): void {
    // Initial refresh
    this.refresh();

    // Schedule periodic refreshes
    this.intervalId = window.setInterval(() => {
      this.refresh();
    }, this.refreshInterval);

    // Also refresh on visibility change
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') {
        this.refresh();
      }
    });
  }

  stop(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }

  private async refresh(): Promise<void> {
    // Don't refresh if offline
    if (!navigator.onLine) return;

    try {
      const products = await fetch('/api/products?_t=' + Date.now()).then((r) => r.json());

      this.dcupl.data.update(products, { model: 'Product' });
    } catch (error) {
      console.warn('Background refresh failed:', error);
    }
  }
}

// Usage
const refresher = new BackgroundRefresher(dcupl, 5 * 60 * 1000);
refresher.start();

Multi-Level Caching

flowchart TB
  Q[Query] --> L1{Memory Cache}
  L1 -->|Hit| R[Return]
  L1 -->|Miss| L2{Session Storage}
  L2 -->|Hit| P1[Promote to L1]
  P1 --> R
  L2 -->|Miss| L3{IndexedDB}
  L3 -->|Hit| P2[Promote to L1]
  P2 --> R
  L3 -->|Miss| F[Fetch Data]
  F --> S[Store in All Levels]
  S --> R

Implement multi-level caching strategy:

multi-level-cache.ts
class MultiLevelCache {
  private memoryCache: Map<string, unknown> = new Map();
  private idbCache: IndexedDBCache;

  constructor() {
    this.idbCache = new IndexedDBCache();
  }

  async get<T>(key: string): Promise<T | null> {
    // Level 1: Memory (fastest)
    if (this.memoryCache.has(key)) {
      return this.memoryCache.get(key);
    }

    // Level 2: Session storage
    const sessionData = sessionStorage.getItem(key);
    if (sessionData) {
      const parsed = JSON.parse(sessionData);
      this.memoryCache.set(key, parsed); // Promote to L1
      return parsed;
    }

    // Level 3: IndexedDB (persistent)
    const idbData = await this.idbCache.get<T>(key);
    if (idbData) {
      this.memoryCache.set(key, idbData); // Promote to L1
      return idbData;
    }

    return null;
  }

  async set<T>(key: string, value: T, options?: { persistent?: boolean }): Promise<void> {
    // Always store in memory
    this.memoryCache.set(key, value);

    // Store in session (survives refresh)
    try {
      sessionStorage.setItem(key, JSON.stringify(value));
    } catch {
      // Session storage full, skip
    }

    // Optionally persist to IndexedDB
    if (options?.persistent) {
      await this.idbCache.set(key, value);
    }
  }

  async clear(key?: string): Promise<void> {
    if (key) {
      this.memoryCache.delete(key);
      sessionStorage.removeItem(key);
      await this.idbCache.delete(key);
    } else {
      this.memoryCache.clear();
      sessionStorage.clear();
      await this.idbCache.clear();
    }
  }
}

// Usage
const mlCache = new MultiLevelCache();

async function loadProductsWithMultiLevelCache() {
  let products = await mlCache.get<Product[]>('products');

  if (!products) {
    products = await fetch('/api/products').then((r) => r.json());
    await mlCache.set('products', products, { persistent: true });
  }

  dcupl.data.set(products, { model: 'Product' });
  await dcupl.init();
}

Best Practices

1. Leverage Automatic Caching

// Good: Reuse lists
const list = dcupl.lists.create({ modelKey: 'Product' });

function getProducts(category: string) {
  list.catalog.query.clear();
  list.catalog.query.addCondition({
    attribute: 'category',
    operator: 'eq',
    value: category,
  });
  return list.catalog.query.items(); // Cached
}

// Bad: Create new lists
function getProducts(category: string) {
  const list = dcupl.lists.create({ modelKey: 'Product' });
  // ... query
  // No caching benefit
}

2. Use autoUpdate Sparingly

// Good: Enable only for real-time data
const realtimeList = dcupl.lists.create({
  modelKey: 'Metric',
  autoUpdate: true,
});

// Bad: Enable everywhere
const staticList = dcupl.lists.create({
  modelKey: 'Category',
  autoUpdate: true, // Unnecessary overhead
});

3. Implement TTL for API Caching

// Good: Use TTL
class APICache {
  private ttl = 300000; // 5 minutes
  // ... implementation
}

// Bad: Cache forever
const data = localStorage.getItem('products');
// Never refreshed!

4. Clear Cache When Needed

// Good: Clear on logout
function logout() {
  dcupl.lists.getAll().forEach((list) => list.destroy());
  dcupl.data.clear();
  localStorage.clear();
  await idbCache.clear();
}

// Bad: Never clear cache
function logout() {
  // Stale data remains
}

5. Monitor Cache Size

// Good: Monitor and limit cache
class Cache<T> {
  private maxSize = 100;

  set(key: string, value: T) {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

Troubleshooting

Stale Data

Problem: Cache returns outdated data

Solution: Invalidate cache after updates:

dcupl.data.update([{ key: 'p1', price: 999 }], { model: 'Product' });

// Force refresh
await list.update();

// Also invalidate external caches
await idbCache.delete('products');
swrCache.invalidate('products:all');

Cache Growing Too Large

Problem: Memory usage increases

Solution: Implement cache size limits:

class LRUCache<T> {
  private maxSize = 100;
  private cache = new Map<string, T>();

  set(key: string, value: T) {
    // Delete oldest if at capacity
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

IndexedDB Quota Exceeded

Problem: IndexedDB storage full

Solution: Implement cleanup and compression:

async function cleanupOldData() {
  const cache = new IndexedDBCache();
  await cache.open();

  // Remove entries older than 7 days
  const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
  // Iterate and delete old entries
}

Inconsistent Results

Problem: Different lists show different data

Solution: Ensure single source of truth:

// Good: Single dcupl instance
const dcupl = new Dcupl();
const list1 = dcupl.lists.create({ modelKey: 'Product' });
const list2 = dcupl.lists.create({ modelKey: 'Product' });

// Bad: Multiple instances
const dcupl1 = new Dcupl();
const dcupl2 = new Dcupl();

What's Next?