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