skills/web-pwa-offline-first/examples/indexeddb.md

21 KiB

IndexedDB Patterns

IndexedDB setup and usage patterns for offline-first applications. See SKILL.md for concepts.


Pattern 12: Dexie.js Database Setup (v4.x)

Initialize IndexedDB with Dexie.js 4.x for type-safe database operations. Dexie 4.0+ includes improved TypeScript support with Entity<T> generics and class mappings.

Database Schema Definition

// db/database.ts
import Dexie, { type Table } from "dexie";

// Sync metadata interface for all syncable tables
interface SyncMetadata {
  _syncStatus: "synced" | "pending" | "conflicted";
  _lastModified: number;
  _localVersion: string;
  _serverVersion?: string;
  _deletedAt?: number;
}

// Entity interfaces
interface Todo extends SyncMetadata {
  id: string;
  title: string;
  completed: boolean;
  userId: string;
  createdAt: number;
}

interface User extends SyncMetadata {
  id: string;
  email: string;
  name: string;
  preferences: UserPreferences;
}

interface UserPreferences {
  theme: "light" | "dark" | "system";
  notifications: boolean;
}

// Sync queue operation
interface QueuedOperation {
  id: string;
  type: "CREATE" | "UPDATE" | "DELETE" | "UPSERT";
  collection: string;
  data: unknown;
  timestamp: number;
  retryCount: number;
  lastError?: string;
}

// Version constants
const CURRENT_DB_VERSION = 3;

class AppDatabase extends Dexie {
  // Declare tables with types
  todos!: Table<Todo, string>;
  users!: Table<User, string>;
  syncQueue!: Table<QueuedOperation, string>;

  constructor() {
    super("myapp-db");

    // Version 1: Initial schema
    this.version(1).stores({
      todos: "id, userId, _syncStatus, _lastModified",
      users: "id, email, _syncStatus",
      syncQueue: "id, collection, timestamp",
    });

    // Version 2: Add compound indexes
    this.version(2).stores({
      todos: "id, userId, _syncStatus, _lastModified, [userId+completed]",
      users: "id, email, _syncStatus",
      syncQueue: "id, collection, timestamp, retryCount",
    });

    // Version 3: Add deleted index for cleanup
    this.version(CURRENT_DB_VERSION).stores({
      todos:
        "id, userId, _syncStatus, _lastModified, _deletedAt, [userId+completed]",
      users: "id, email, _syncStatus, _deletedAt",
      syncQueue: "id, collection, timestamp, retryCount",
    });
  }

  // Helper method to clear all sync-related data
  async clearSyncState(): Promise<void> {
    await this.transaction("rw", [this.todos, this.users], async () => {
      await this.todos.toCollection().modify({
        _syncStatus: "pending",
        _serverVersion: undefined,
      });
      await this.users.toCollection().modify({
        _syncStatus: "pending",
        _serverVersion: undefined,
      });
    });
  }

  // Cleanup soft-deleted items older than retention period
  async cleanupTombstones(
    retentionMs: number = 30 * 24 * 60 * 60 * 1000,
  ): Promise<number> {
    const threshold = Date.now() - retentionMs;
    let deletedCount = 0;

    await this.transaction("rw", [this.todos, this.users], async () => {
      // Only cleanup synced tombstones
      deletedCount += await this.todos
        .where("_deletedAt")
        .below(threshold)
        .and((item) => item._syncStatus === "synced")
        .delete();

      deletedCount += await this.users
        .where("_deletedAt")
        .below(threshold)
        .and((item) => item._syncStatus === "synced")
        .delete();
    });

    return deletedCount;
  }
}

// Singleton database instance
const db = new AppDatabase();

// Database initialization check
async function initDatabase(): Promise<AppDatabase> {
  try {
    await db.open();
    return db;
  } catch (error) {
    console.error("Failed to open database:", error);
    throw error;
  }
}

export { db, initDatabase, AppDatabase };
export type { Todo, User, UserPreferences, QueuedOperation, SyncMetadata };

Why good: Type-safe table declarations, versioned migrations, indexed sync metadata fields, compound indexes for complex queries, tombstone cleanup method


Pattern 13: Dexie Hooks for CRUD Operations

Hooks that integrate Dexie with component state.

// hooks/use-dexie-crud.ts
import { useCallback } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../db/database";
import type { Todo, SyncMetadata } from "../db/database";

// Sync status constants
const SYNC_STATUS = {
  SYNCED: "synced",
  PENDING: "pending",
  CONFLICTED: "conflicted",
} as const;

// Generic CRUD hook factory
function createCrudHooks<T extends SyncMetadata & { id: string }>(
  tableName: "todos" | "users",
) {
  function useGetAll(filter?: (item: T) => boolean) {
    return useLiveQuery(async () => {
      let collection = db[tableName].toCollection();

      // Exclude soft-deleted
      const items = await collection.toArray();
      let filtered = items.filter((item) => !item._deletedAt) as T[];

      if (filter) {
        filtered = filtered.filter(filter);
      }

      // Sort by last modified, newest first
      return filtered.sort((a, b) => b._lastModified - a._lastModified);
    }, [filter]);
  }

  function useGetById(id: string | undefined) {
    return useLiveQuery(async () => {
      if (!id) return null;
      const item = await db[tableName].get(id);
      if (item?._deletedAt) return null;
      return item as T | null;
    }, [id]);
  }

  return { useGetAll, useGetById };
}

// Create Todo-specific hooks
const todoHooks = createCrudHooks<Todo>("todos");

function useTodos(userId?: string) {
  return todoHooks.useGetAll(
    userId ? (todo) => todo.userId === userId : undefined,
  );
}

function useTodo(id: string | undefined) {
  return todoHooks.useGetById(id);
}

// Mutation functions (not hooks, just async functions)
async function createTodo(title: string, userId: string): Promise<Todo> {
  const todo: Todo = {
    id: crypto.randomUUID(),
    title,
    completed: false,
    userId,
    createdAt: Date.now(),
    _syncStatus: SYNC_STATUS.PENDING,
    _lastModified: Date.now(),
    _localVersion: crypto.randomUUID(),
  };

  await db.todos.add(todo);
  return todo;
}

async function updateTodo(
  id: string,
  updates: Partial<Pick<Todo, "title" | "completed">>,
): Promise<void> {
  await db.todos.update(id, {
    ...updates,
    _syncStatus: SYNC_STATUS.PENDING,
    _lastModified: Date.now(),
    _localVersion: crypto.randomUUID(),
  });
}

async function deleteTodo(id: string): Promise<void> {
  // Soft delete
  await db.todos.update(id, {
    _deletedAt: Date.now(),
    _syncStatus: SYNC_STATUS.PENDING,
    _lastModified: Date.now(),
    _localVersion: crypto.randomUUID(),
  });
}

// Batch operations
async function markTodosComplete(ids: string[]): Promise<void> {
  await db.transaction("rw", db.todos, async () => {
    for (const id of ids) {
      await updateTodo(id, { completed: true });
    }
  });
}

async function clearCompletedTodos(userId: string): Promise<void> {
  const completedTodos = await db.todos
    .where("[userId+completed]")
    .equals([userId, 1]) // 1 = true in IndexedDB
    .toArray();

  await db.transaction("rw", db.todos, async () => {
    for (const todo of completedTodos) {
      await deleteTodo(todo.id);
    }
  });
}

export {
  useTodos,
  useTodo,
  createTodo,
  updateTodo,
  deleteTodo,
  markTodosComplete,
  clearCompletedTodos,
};

Why good: useLiveQuery provides reactive updates, soft deletes maintain sync integrity, batch operations use transactions, type-safe throughout


Pattern 14: idb Library Alternative (v8.x)

Using the lighter-weight idb library (~1.2KB brotli) instead of Dexie. Version 8.0.3 is current stable.

CRITICAL: Do not await non-IndexedDB operations (like fetch()) mid-transaction. Transactions auto-close when control returns to the event loop without pending requests.

// db/idb-database.ts
import { openDB, type DBSchema, type IDBPDatabase } from "idb";

// Schema definition
interface AppDBSchema extends DBSchema {
  todos: {
    key: string;
    value: {
      id: string;
      title: string;
      completed: boolean;
      userId: string;
      createdAt: number;
      _syncStatus: "synced" | "pending" | "conflicted";
      _lastModified: number;
      _localVersion: string;
      _serverVersion?: string;
      _deletedAt?: number;
    };
    indexes: {
      "by-user": string;
      "by-sync-status": string;
      "by-deleted": number;
    };
  };
  syncQueue: {
    key: string;
    value: {
      id: string;
      type: "CREATE" | "UPDATE" | "DELETE";
      collection: string;
      data: unknown;
      timestamp: number;
      retryCount: number;
    };
    indexes: {
      "by-timestamp": number;
    };
  };
}

// Database version
const DB_VERSION = 1;
const DB_NAME = "myapp-idb";

async function initIdbDatabase(): Promise<IDBPDatabase<AppDBSchema>> {
  return openDB<AppDBSchema>(DB_NAME, DB_VERSION, {
    upgrade(db, oldVersion, newVersion, transaction) {
      // Version 1: Initial schema
      if (oldVersion < 1) {
        // Todos store
        const todosStore = db.createObjectStore("todos", { keyPath: "id" });
        todosStore.createIndex("by-user", "userId");
        todosStore.createIndex("by-sync-status", "_syncStatus");
        todosStore.createIndex("by-deleted", "_deletedAt");

        // Sync queue store
        const syncStore = db.createObjectStore("syncQueue", { keyPath: "id" });
        syncStore.createIndex("by-timestamp", "timestamp");
      }

      // Future versions would add more conditions:
      // if (oldVersion < 2) { ... }
    },
    blocked() {
      console.warn("Database upgrade blocked by other tabs");
    },
    blocking() {
      // Close connection in other tabs
      console.warn("Blocking other tabs from upgrade");
    },
  });
}

// Repository wrapper for idb
class IdbTodoRepository {
  constructor(private db: IDBPDatabase<AppDBSchema>) {}

  async getAll(userId: string): Promise<AppDBSchema["todos"]["value"][]> {
    const todos = await this.db.getAllFromIndex("todos", "by-user", userId);
    return todos.filter((todo) => !todo._deletedAt);
  }

  async get(id: string): Promise<AppDBSchema["todos"]["value"] | null> {
    const todo = await this.db.get("todos", id);
    if (todo?._deletedAt) return null;
    return todo ?? null;
  }

  async save(todo: AppDBSchema["todos"]["value"]): Promise<void> {
    await this.db.put("todos", {
      ...todo,
      _syncStatus: "pending",
      _lastModified: Date.now(),
      _localVersion: crypto.randomUUID(),
    });
  }

  async delete(id: string): Promise<void> {
    const existing = await this.db.get("todos", id);
    if (!existing) return;

    await this.db.put("todos", {
      ...existing,
      _deletedAt: Date.now(),
      _syncStatus: "pending",
      _lastModified: Date.now(),
      _localVersion: crypto.randomUUID(),
    });
  }

  async getPending(): Promise<AppDBSchema["todos"]["value"][]> {
    return this.db.getAllFromIndex("todos", "by-sync-status", "pending");
  }

  async markSynced(id: string, serverVersion: string): Promise<void> {
    const todo = await this.db.get("todos", id);
    if (!todo) return;

    await this.db.put("todos", {
      ...todo,
      _syncStatus: "synced",
      _serverVersion: serverVersion,
    });
  }
}

export { initIdbDatabase, IdbTodoRepository };
export type { AppDBSchema };

Why good: Lighter weight than Dexie (~1.2KB vs ~22KB), strongly typed with DBSchema, proper index usage, migration support via upgrade function, promises instead of event listeners

Version Notes:

  • idb 8.0.3: Current stable version, CDN available at https://cdn.jsdelivr.net/npm/idb@8/+esm
  • For simple key-value storage: Use idb-keyval (~295 bytes) instead

Pattern 15: Database Migration with Data Transform

Handle complex schema migrations that require data transformation.

// db/migrations.ts
import Dexie from "dexie";
import { db } from "./database";

// Migration version constants
const MIGRATION_VERSIONS = {
  INITIAL: 1,
  ADD_SYNC_METADATA: 2,
  NORMALIZE_TIMESTAMPS: 3,
} as const;

// Configure migrations with data transforms
db.version(MIGRATION_VERSIONS.INITIAL).stores({
  todos: "id, userId",
});

db.version(MIGRATION_VERSIONS.ADD_SYNC_METADATA)
  .stores({
    todos: "id, userId, _syncStatus, _lastModified",
  })
  .upgrade(async (tx) => {
    // Add sync metadata to existing todos
    const todos = tx.table("todos");
    await todos.toCollection().modify((todo) => {
      if (!todo._syncStatus) {
        todo._syncStatus = "synced"; // Existing data is assumed synced
        todo._lastModified = Date.now();
        todo._localVersion = crypto.randomUUID();
      }
    });
  });

db.version(MIGRATION_VERSIONS.NORMALIZE_TIMESTAMPS)
  .stores({
    todos: "id, userId, _syncStatus, _lastModified, createdAt",
  })
  .upgrade(async (tx) => {
    // Normalize timestamps from ISO strings to numbers
    const todos = tx.table("todos");
    await todos.toCollection().modify((todo) => {
      if (typeof todo.createdAt === "string") {
        todo.createdAt = new Date(todo.createdAt).getTime();
      }
      if (typeof todo._lastModified === "string") {
        todo._lastModified = new Date(todo._lastModified).getTime();
      }
    });
  });

// Export migration status check
async function checkMigrationStatus(): Promise<{
  currentVersion: number;
  latestVersion: number;
  needsMigration: boolean;
}> {
  const currentVersion = db.verno;
  const latestVersion = MIGRATION_VERSIONS.NORMALIZE_TIMESTAMPS;

  return {
    currentVersion,
    latestVersion,
    needsMigration: currentVersion < latestVersion,
  };
}

export { checkMigrationStatus, MIGRATION_VERSIONS };

Why good: Named version constants for clarity, upgrade functions transform existing data, migrations are idempotent (check before modify), status check for debugging


Pattern 16: Multi-Tab Coordination

Handle multiple browser tabs accessing the same IndexedDB.

// db/tab-coordinator.ts

// Named constants
const LOCK_TIMEOUT_MS = 30000;
const HEARTBEAT_INTERVAL_MS = 5000;
const STALE_LOCK_THRESHOLD_MS = 10000;

interface TabLock {
  tabId: string;
  timestamp: number;
  operation: string;
}

class TabCoordinator {
  private readonly tabId = crypto.randomUUID();
  private readonly channel: BroadcastChannel;
  private activeLocks = new Map<string, TabLock>();
  private heartbeatInterval: ReturnType<typeof setInterval> | null = null;

  constructor() {
    this.channel = new BroadcastChannel("app-tab-coordinator");
    this.setupListeners();
    this.startHeartbeat();
  }

  private setupListeners(): void {
    this.channel.onmessage = (event) => {
      const { type, payload } = event.data;

      switch (type) {
        case "LOCK_ACQUIRED":
          this.activeLocks.set(payload.operation, payload);
          break;

        case "LOCK_RELEASED":
          this.activeLocks.delete(payload.operation);
          break;

        case "HEARTBEAT":
          // Update timestamp for existing locks from this tab
          if (
            this.activeLocks.get(payload.operation)?.tabId === payload.tabId
          ) {
            this.activeLocks.set(payload.operation, payload);
          }
          break;

        case "DB_CHANGED":
          // Another tab made changes - emit event for UI refresh
          window.dispatchEvent(
            new CustomEvent("database-changed", { detail: payload }),
          );
          break;
      }
    };
  }

  private startHeartbeat(): void {
    this.heartbeatInterval = setInterval(() => {
      // Send heartbeat for any locks this tab holds
      for (const [operation, lock] of this.activeLocks) {
        if (lock.tabId === this.tabId) {
          this.channel.postMessage({
            type: "HEARTBEAT",
            payload: { ...lock, timestamp: Date.now() },
          });
        }
      }

      // Clean up stale locks
      this.cleanupStaleLocks();
    }, HEARTBEAT_INTERVAL_MS);
  }

  private cleanupStaleLocks(): void {
    const now = Date.now();

    for (const [operation, lock] of this.activeLocks) {
      if (now - lock.timestamp > STALE_LOCK_THRESHOLD_MS) {
        this.activeLocks.delete(operation);
      }
    }
  }

  async acquireLock(operation: string): Promise<boolean> {
    const existingLock = this.activeLocks.get(operation);

    // Check if lock exists and is not stale
    if (
      existingLock &&
      Date.now() - existingLock.timestamp < STALE_LOCK_THRESHOLD_MS
    ) {
      return false; // Lock held by another tab
    }

    const lock: TabLock = {
      tabId: this.tabId,
      timestamp: Date.now(),
      operation,
    };

    this.activeLocks.set(operation, lock);
    this.channel.postMessage({ type: "LOCK_ACQUIRED", payload: lock });

    return true;
  }

  releaseLock(operation: string): void {
    const lock = this.activeLocks.get(operation);

    if (lock?.tabId === this.tabId) {
      this.activeLocks.delete(operation);
      this.channel.postMessage({
        type: "LOCK_RELEASED",
        payload: { operation },
      });
    }
  }

  notifyDatabaseChange(collection: string, id: string): void {
    this.channel.postMessage({
      type: "DB_CHANGED",
      payload: { collection, id, tabId: this.tabId },
    });
  }

  destroy(): void {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
    }

    // Release all locks held by this tab
    for (const [operation, lock] of this.activeLocks) {
      if (lock.tabId === this.tabId) {
        this.releaseLock(operation);
      }
    }

    this.channel.close();
  }
}

// Singleton instance
const tabCoordinator = new TabCoordinator();

export { tabCoordinator, TabCoordinator };
export type { TabLock };

Why good: BroadcastChannel for inter-tab communication, heartbeat keeps locks fresh, stale lock cleanup prevents deadlocks, notifies other tabs of changes


Pattern 17: IndexedDB Storage Quota Management

Monitor and manage storage usage to prevent quota exceeded errors.

// db/storage-manager.ts
import { useState, useEffect } from "react";

// Constants
const STORAGE_WARNING_THRESHOLD = 0.8; // 80%
const STORAGE_CRITICAL_THRESHOLD = 0.95; // 95%
const MIN_FREE_SPACE_MB = 50;

interface StorageInfo {
  usedBytes: number;
  totalBytes: number;
  percentUsed: number;
  status: "ok" | "warning" | "critical";
}

async function getStorageInfo(): Promise<StorageInfo> {
  if (!navigator.storage?.estimate) {
    // Fallback for browsers without StorageManager
    return {
      usedBytes: 0,
      totalBytes: 0,
      percentUsed: 0,
      status: "ok",
    };
  }

  const estimate = await navigator.storage.estimate();
  const used = estimate.usage ?? 0;
  const total = estimate.quota ?? 0;
  const percentUsed = total > 0 ? used / total : 0;

  let status: StorageInfo["status"] = "ok";
  if (percentUsed >= STORAGE_CRITICAL_THRESHOLD) {
    status = "critical";
  } else if (percentUsed >= STORAGE_WARNING_THRESHOLD) {
    status = "warning";
  }

  return {
    usedBytes: used,
    totalBytes: total,
    percentUsed,
    status,
  };
}

async function requestPersistentStorage(): Promise<boolean> {
  if (!navigator.storage?.persist) {
    return false;
  }

  // Check if already persisted
  const isPersisted = await navigator.storage.persisted();
  if (isPersisted) {
    return true;
  }

  // Request persistence
  return navigator.storage.persist();
}

// Cleanup strategies when storage is low
interface CleanupResult {
  freedBytes: number;
  itemsRemoved: number;
}

async function performStorageCleanup(
  db: LocalDatabase,
): Promise<CleanupResult> {
  let freedBytes = 0;
  let itemsRemoved = 0;

  // Strategy 1: Remove synced tombstones older than 7 days
  const TOMBSTONE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
  const tombstoneThreshold = Date.now() - TOMBSTONE_AGE_MS;

  const tombstones = await db.todos
    .where("_deletedAt")
    .below(tombstoneThreshold)
    .and((item) => item._syncStatus === "synced")
    .toArray();

  for (const item of tombstones) {
    await db.todos.delete(item.id);
    itemsRemoved++;
    freedBytes += estimateObjectSize(item);
  }

  // Strategy 2: Clear old cache entries (if using cache table)
  // Add more cleanup strategies as needed

  return { freedBytes, itemsRemoved };
}

// Rough estimate of object size in bytes
function estimateObjectSize(obj: unknown): number {
  return new Blob([JSON.stringify(obj)]).size;
}

// Hook for React components
const STORAGE_POLL_INTERVAL_MS = 60_000;

function useStorageInfo() {
  const [info, setInfo] = useState<StorageInfo | null>(null);

  useEffect(() => {
    getStorageInfo().then(setInfo);

    const interval = setInterval(() => {
      getStorageInfo().then(setInfo);
    }, STORAGE_POLL_INTERVAL_MS);

    return () => clearInterval(interval);
  }, []);

  return info;
}

export {
  getStorageInfo,
  requestPersistentStorage,
  performStorageCleanup,
  useStorageInfo,
};
export type { StorageInfo, CleanupResult };

Why good: Proactive monitoring prevents quota errors, persistent storage prevents data eviction, cleanup strategies free space intelligently, size estimation for tracking