Skip to main content

Dexie IndexedDB

MStore Dashboard menggunakan Dexie sebagai wrapper untuk IndexedDB, menyediakan client-side storage untuk caching dan offline capabilities.

Overview

Installation

Dexie sudah terinstall via package.json:
{
  "dependencies": {
    "dexie": "^4.2.1"
  }
}

Database Setup

Basic Database Class

// utils/db/database.ts
import Dexie, { Table } from 'dexie'

// Define entity types
export interface CachedProduct {
  id: string
  name: string
  sku: string
  price: number
  category: string
  syncedAt: number
}

export interface CachedCountry {
  id: number
  name: string
  code: string
  phonecode: string
}

export interface CachedState {
  id: number
  name: string
  countryCode: string
}

export interface CachedCity {
  id: number
  name: string
  stateId: number
}

// Database class
export class AppDatabase extends Dexie {
  products!: Table<CachedProduct>
  countries!: Table<CachedCountry>
  states!: Table<CachedState>
  cities!: Table<CachedCity>

  constructor() {
    super('MStoreDB')

    this.version(1).stores({
      products: 'id, name, sku, category, syncedAt',
      countries: 'id, name, code',
      states: 'id, name, countryCode',
      cities: 'id, name, stateId',
    })
  }
}

// Singleton instance
export const db = new AppDatabase()

Geographic Data Store

// utils/db/geoStore.ts
import Dexie, { Table } from 'dexie'

interface Country {
  id: number
  name: string
  iso2: string
  iso3: string
  phonecode: string
  capital: string
  currency: string
  region: string
}

interface State {
  id: number
  name: string
  countryId: number
  countryCode: string
  stateCode: string
}

interface City {
  id: number
  name: string
  stateId: number
  stateCode: string
  countryCode: string
}

class GeoDatabase extends Dexie {
  countries!: Table<Country>
  states!: Table<State>
  cities!: Table<City>

  constructor() {
    super('GeoStore')

    this.version(1).stores({
      countries: 'id, name, iso2, iso3',
      states: 'id, name, countryId, countryCode',
      cities: 'id, name, stateId, countryCode',
    })
  }
}

export const geoDb = new GeoDatabase()

Usage Patterns

Loading Data into IndexedDB

// composables/useGeoData.ts
import { geoDb } from '~/utils/db/geoStore'

export const useGeoData = () => {
  const loading = ref(false)
  const error = ref<string | null>(null)

  // Load countries (from API if not cached)
  const loadCountries = async (): Promise<Country[]> => {
    // Check if already cached
    const count = await geoDb.countries.count()
    if (count > 0) {
      return geoDb.countries.toArray()
    }

    // Fetch from API and cache
    loading.value = true
    try {
      const countries = await $fetch<Country[]>('/api/v1/geo/countries')
      await geoDb.countries.bulkAdd(countries)
      return countries
    } catch (e) {
      error.value = 'Failed to load countries'
      throw e
    } finally {
      loading.value = false
    }
  }

  // Get states by country
  const getStatesByCountry = async (countryCode: string): Promise<State[]> => {
    // Check cache first
    const cached = await geoDb.states
      .where('countryCode')
      .equals(countryCode)
      .toArray()

    if (cached.length > 0) {
      return cached
    }

    // Fetch and cache
    const states = await $fetch<State[]>(`/api/v1/geo/states/${countryCode}`)
    await geoDb.states.bulkAdd(states)
    return states
  }

  // Get cities by state
  const getCitiesByState = async (stateId: number): Promise<City[]> => {
    const cached = await geoDb.cities
      .where('stateId')
      .equals(stateId)
      .toArray()

    if (cached.length > 0) {
      return cached
    }

    const cities = await $fetch<City[]>(`/api/v1/geo/cities/${stateId}`)
    await geoDb.cities.bulkAdd(cities)
    return cities
  }

  // Clear all geo data
  const clearGeoData = async () => {
    await Promise.all([
      geoDb.countries.clear(),
      geoDb.states.clear(),
      geoDb.cities.clear(),
    ])
  }

  return {
    loading,
    error,
    loadCountries,
    getStatesByCountry,
    getCitiesByState,
    clearGeoData,
  }
}

Product Cache

// features/03_inventory/composables/useProductCache.ts
import { db } from '~/utils/db/database'
import type { Product } from '../types'

const CACHE_TTL = 1000 * 60 * 30 // 30 minutes

export const useProductCache = () => {
  // Get cached products
  const getCachedProducts = async (): Promise<Product[] | null> => {
    const products = await db.products.toArray()

    if (products.length === 0) {
      return null
    }

    // Check if cache is stale
    const oldestSync = Math.min(...products.map(p => p.syncedAt))
    if (Date.now() - oldestSync > CACHE_TTL) {
      return null // Cache is stale
    }

    return products
  }

  // Update cache
  const updateCache = async (products: Product[]) => {
    const now = Date.now()
    const cached = products.map(p => ({
      ...p,
      syncedAt: now,
    }))

    await db.transaction('rw', db.products, async () => {
      await db.products.clear()
      await db.products.bulkAdd(cached)
    })
  }

  // Get single product from cache
  const getCachedProduct = async (id: string): Promise<Product | null> => {
    return await db.products.get(id) ?? null
  }

  // Update single product in cache
  const updateCachedProduct = async (product: Product) => {
    await db.products.put({
      ...product,
      syncedAt: Date.now(),
    })
  }

  // Remove from cache
  const removeFromCache = async (id: string) => {
    await db.products.delete(id)
  }

  // Clear entire cache
  const clearCache = async () => {
    await db.products.clear()
  }

  return {
    getCachedProducts,
    updateCache,
    getCachedProduct,
    updateCachedProduct,
    removeFromCache,
    clearCache,
  }
}

Integration with Pinia Store

// features/03_inventory/store/inventory.store.ts
import { defineStore } from 'pinia'
import { useProductCache } from '../composables/useProductCache'
import { getInventoryList } from '../api'
import type { Product } from '../types'

export const useInventoryStore = defineStore('inventory', () => {
  const { getCachedProducts, updateCache } = useProductCache()

  const items = ref<Product[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  // Fetch with cache-first strategy
  const fetchItems = async (forceRefresh = false) => {
    loading.value = true
    error.value = null

    try {
      // Try cache first (unless force refresh)
      if (!forceRefresh) {
        const cached = await getCachedProducts()
        if (cached) {
          items.value = cached
          loading.value = false

          // Refresh in background
          refreshInBackground()
          return
        }
      }

      // Fetch from API
      const response = await getInventoryList()
      items.value = response.data

      // Update cache
      await updateCache(response.data)
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Failed to fetch'

      // Try to use stale cache as fallback
      const cached = await getCachedProducts()
      if (cached) {
        items.value = cached
      }
    } finally {
      loading.value = false
    }
  }

  // Background refresh
  const refreshInBackground = async () => {
    try {
      const response = await getInventoryList()
      items.value = response.data
      await updateCache(response.data)
    } catch (e) {
      // Silently fail - we already have cached data
      console.warn('Background refresh failed:', e)
    }
  }

  return {
    items,
    loading,
    error,
    fetchItems,
  }
})

Advanced Operations

Transactions

// Atomic operations
await db.transaction('rw', db.products, db.categories, async () => {
  // All operations in this block are atomic
  await db.products.add(newProduct)
  await db.categories.update(categoryId, {
    productCount: category.productCount + 1,
  })
})

Bulk Operations

// Bulk add
await db.products.bulkAdd(products)

// Bulk update
await db.products.bulkPut(products)

// Bulk delete
await db.products.bulkDelete(productIds)

// With transaction for atomicity
await db.transaction('rw', db.products, async () => {
  await db.products.clear()
  await db.products.bulkAdd(newProducts)
})

Queries

// Simple query
const activeProducts = await db.products
  .where('status')
  .equals('active')
  .toArray()

// Compound query
const laptops = await db.products
  .where('category')
  .equals('electronics')
  .and(p => p.name.toLowerCase().includes('laptop'))
  .toArray()

// Sort
const sortedProducts = await db.products
  .orderBy('name')
  .toArray()

// Reverse sort
const newestFirst = await db.products
  .orderBy('syncedAt')
  .reverse()
  .toArray()

// Limit
const top10 = await db.products
  .orderBy('price')
  .reverse()
  .limit(10)
  .toArray()

// Offset + Limit (pagination)
const page2 = await db.products
  .orderBy('name')
  .offset(20)
  .limit(20)
  .toArray()

Reactive Queries with liveQuery

// composables/useLiveProducts.ts
import { liveQuery } from 'dexie'
import { useObservable } from '@vueuse/rxjs'
import { db } from '~/utils/db/database'

export const useLiveProducts = () => {
  // Reactive query - updates automatically when data changes
  const products = useObservable(
    liveQuery(() => db.products.toArray())
  )

  // Reactive count
  const productCount = useObservable(
    liveQuery(() => db.products.count())
  )

  // Reactive filtered query
  const lowStockProducts = useObservable(
    liveQuery(() =>
      db.products
        .filter(p => p.stock < p.minStock)
        .toArray()
    )
  )

  return {
    products,
    productCount,
    lowStockProducts,
  }
}

Schema Migrations

// utils/db/database.ts
export class AppDatabase extends Dexie {
  products!: Table<CachedProduct>

  constructor() {
    super('MStoreDB')

    // Version 1 - Initial schema
    this.version(1).stores({
      products: 'id, name, sku, category',
    })

    // Version 2 - Add new index
    this.version(2).stores({
      products: 'id, name, sku, category, status',
    })

    // Version 3 - Add new table
    this.version(3).stores({
      products: 'id, name, sku, category, status',
      categories: 'id, name, parentId',
    })

    // Version 4 - Migration with data transformation
    this.version(4)
      .stores({
        products: 'id, name, sku, category, status, price',
      })
      .upgrade(async tx => {
        // Transform existing data
        await tx.table('products').toCollection().modify(product => {
          product.price = product.price || 0
        })
      })
  }
}

Error Handling

import Dexie from 'dexie'

try {
  await db.products.add(product)
} catch (error) {
  if (error instanceof Dexie.ConstraintError) {
    console.error('Duplicate key:', error)
  } else if (error instanceof Dexie.QuotaExceededError) {
    console.error('Storage quota exceeded')
    // Maybe clear old data
    await clearOldData()
  } else {
    throw error
  }
}

// Clear old cached data
const clearOldData = async () => {
  const oneWeekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000)

  await db.products
    .where('syncedAt')
    .below(oneWeekAgo)
    .delete()
}

Best Practices

// Good - indexed field
await db.products.where('category').equals('electronics')

// Slower - non-indexed filter
await db.products.filter(p => p.description.includes('laptop'))
// TTL-based invalidation
const CACHE_TTL = 30 * 60 * 1000 // 30 minutes

const isCacheValid = (syncedAt: number) =>
  Date.now() - syncedAt < CACHE_TTL
// Check storage estimate
const estimate = await navigator.storage.estimate()
const percentUsed = (estimate.usage! / estimate.quota!) * 100

if (percentUsed > 80) {
  await clearOldData()
}

Next Steps