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:Copy
{
"dependencies": {
"dexie": "^4.2.1"
}
}
Database Setup
Basic Database Class
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
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
Use Transactions for Related Operations
Use Transactions for Related Operations
Copy
await db.transaction('rw', db.products, db.inventory, async () => {
await db.products.add(product)
await db.inventory.add({ productId: product.id, stock: 0 })
})
Index Frequently Queried Fields
Index Frequently Queried Fields
Copy
// Good - indexed field
await db.products.where('category').equals('electronics')
// Slower - non-indexed filter
await db.products.filter(p => p.description.includes('laptop'))
Implement Cache Invalidation
Implement Cache Invalidation
Copy
// TTL-based invalidation
const CACHE_TTL = 30 * 60 * 1000 // 30 minutes
const isCacheValid = (syncedAt: number) =>
Date.now() - syncedAt < CACHE_TTL
Handle Storage Limits
Handle Storage Limits
Copy
// Check storage estimate
const estimate = await navigator.storage.estimate()
const percentUsed = (estimate.usage! / estimate.quota!) * 100
if (percentUsed > 80) {
await clearOldData()
}