UNPKG

use-dexie

Version:

React Hooks to use Dexie.js IndexDB library with ease

567 lines (466 loc) 14 kB
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import Dexie from 'dexie'; import 'dexie-observable'; import { nanoid } from 'nanoid'; import isEqual from 'lodash.isequal'; const emptyObj = {}; let db; let monitor = false; class TransactionMonitor { constructor() { this.active = new Map(); this.fulfilled = new Map(); this.maxActive = 0; this.elapsed = []; } getStats() { const active = this.active.size; const fulfilled = this.fulfilled.size; const avg = (this.elapsed.reduce((acc, val) => acc + val, 0) / this.elapsed.length).toFixed(2); const avgLast10 = ( this.elapsed.slice(0, 10).reduce((acc, val) => acc + val, 0) / this.elapsed.length ).toFixed(2); return { maxActive: this.maxActive, active, fulfilled, avg, avgLast10, }; } start() { const id = nanoid(); const startTime = Date.now(); this.active.set(id, { id, startTime, open: true }); const active = this.active.size; if (active > this.maxActive) this.maxActive = active; return id; } end(id) { const tx = this.active.get(id); const elapsed = Date.now() - tx.startTime; this.elapsed.push(elapsed); this.active.delete(id); this.fulfilled.set(id, { id, elapsed, open: false }); } } class DBDispatcher { constructor() { this.subscribers = new Map(); this.subscriptions = new Map(); this.engaged = false; } getStats() { const subscribers = this.subscribers.size; const subscriptions = this.subscriptions.size; return { subscriptions, tables: [...this.subscriptions.keys()], subscribers, }; } dispatchChanges(changes) { const changedTables = new Set(changes.map((change) => change.table)); for (const key of changedTables) { (this.subscriptions.get(key) || []).forEach((subscriber) => { subscriber.cb(key); }); } } subscribe(subscriberId, key, cb) { if (!this.engaged) { this.engaged = true; db.on('changes', (changes) => this.dispatchChanges(changes)); } if (!this.subscribers.has(subscriberId)) { this.subscribers.set(subscriberId, key); this.subscriptions.set(key, [ ...(this.subscriptions.get(key) || []), { id: subscriberId, cb }, ]); } } unsubscribe(subscriberId) { const key = this.subscribers.get(subscriberId); if (key) { const subscriptions = this.subscriptions.get(key).filter((s) => s.id !== subscriberId); if (subscriptions.length === 0) this.subscriptions.delete(key); else this.subscriptions.set(key, subscriptions); } this.subscribers.delete(subscriberId); } } const dbDispatcher = new DBDispatcher(); const trxMonitor = new TransactionMonitor(); function executeTransaction(key, query, cb, cbError) { let txId = monitor ? trxMonitor.start() : null; db.transaction('rw?', db.table(key), (tx) => { return query(tx.table(key)); }) .then((data) => { if (txId) trxMonitor.end(txId); return cb(data); }) .catch((error) => { if (txId) trxMonitor.end(txId); if (cbError) cbError(error); else throw new Error(`useDexie: Transaction ${key}: ${error}`); }); } function transaction(key, query, cb, cbError) { if (db.isOpen()) executeTransaction(key, query, cb, cbError); else { db.open().then(() => executeTransaction(key, query, cb, cbError)); } } function composeWhere(query, where, join, forceWhere) { for (const { field, operator, value = '', param, filter, or, and } of where) { const joiner = !join || forceWhere ? 'where' : join; const Value = (() => { if (Array.isArray(value)) return JSON.stringify(value); if (typeof value === 'boolean') return value; if (!isNaN(value)) return Number(value); const multiple = value.split(','); if (multiple.length === 1) return `"${value}"`; else { const values = multiple.map((m) => { if (typeof value === 'boolean') return value; if (!isNaN(value)) return Number(value); return `"${value}"`; }); return values.join(','); } })(); let missingWhere = joiner === 'where' && !field; if (!missingWhere) { forceWhere = false; if (['or', 'where'].includes(joiner)) { //console.log(`query.${joiner}('${field}').${operator}(${Value})`); query = new Function('query', `return query.${joiner}('${field}').${operator}(${Value})`)( query ); } else { //console.log(`query.and(${filter.toString()})`, param); query = new Function('query,param', `return query.and(${filter.toString()})`)(query, param); } } if (or) query = composeWhere(query, or, 'or', missingWhere); if (and) query = composeWhere(query, and, 'and', missingWhere); } return query; } function composeQuery(dbTable, query = emptyObj) { const { where, orderBy, reverse, offset, limit, filter, count, primaryKeys, erase, toArray = true, } = query; const canOrderBy = orderBy ? dbTable.schema.indexes.findIndex((i) => i.keyPath === orderBy) > -1 && !where : false; if (where !== undefined && where.length > 0) dbTable = composeWhere(dbTable, where); if (canOrderBy) dbTable = dbTable.orderBy(orderBy); if (filter !== undefined) dbTable = dbTable.filter(filter); if (reverse !== undefined) dbTable = dbTable.reverse(); if (offset !== undefined) dbTable = dbTable.offset(offset); if (limit !== undefined) dbTable = dbTable.limit(limit); if (count === true) dbTable = dbTable.count(); if (toArray && !count && !primaryKeys && !erase) dbTable = dbTable.toArray(); if (primaryKeys) dbTable = dbTable.primaryKeys(); if (erase === true) dbTable = dbTable.delete(); return dbTable; } function useSubscribeToDBChanges(key, cb) { const callerIdRef = useRef(nanoid()); const subscribe = useCallback((key, cb) => { if (key) dbDispatcher.subscribe(callerIdRef.current, key, cb); }, []); useEffect(() => { if (key) dbDispatcher.subscribe(callerIdRef.current, key, cb); // eslint-disable-next-line react-hooks/exhaustive-deps return () => dbDispatcher.unsubscribe(callerIdRef.current); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return subscribe; } function useExecuteQuery(key, options) { const [rawData, setRawData] = useState(); const dataRef = useRef(); const optionsRef = useRef(); const subscribeToChanges = useSubscribeToDBChanges(); const execute = useCallback(() => { if (optionsRef.current === null) return; transaction( key, (dbTable) => composeQuery(dbTable, optionsRef.current), (data) => { if (!isEqual(dataRef.current, data)) { dataRef.current = data; setRawData(data); } } ); }, [key]); useEffect(() => { if (!isEqual(optionsRef.current, options)) { optionsRef.current = options; execute(); } }, [execute, options]); useEffect(() => { subscribeToChanges(key, () => execute()); }, [execute, key, subscribeToChanges]); return rawData; } const toObj = (idField = 'id') => (data) => data.reduce((rv, x) => { rv[x[idField]] = x; return rv; }, {}); const toMap = (idField = 'id') => (data) => data.reduce((rv, x) => { rv.set(x[idField], x); return rv; }, new Map()); const toSet = (idField = 'id') => (data) => data.reduce((rv, x) => { rv.add(x[idField]); return rv; }, new Set()); function useDataType(TypeFunc, Table, params) { let options = emptyObj; let idField = 'id'; let cb; params.forEach((param) => { if (typeof param === 'object') options = param; else if (typeof param === 'string') idField = param; else if (typeof param === 'function') cb = param; }); const data = useDexieTable(Table, options, TypeFunc(idField)); return cb && data ? cb(data) : data; } export function useDexie(name, ...params) { const [database, setDatabase] = useState(); useEffect(() => { if (database) return; let version = 1; let schema; let cb; for (const param of params) { if (typeof param === 'object') schema = param; else if (typeof param === 'number') version = param; else if (typeof param === 'function') cb = param; } // Populate default DB db = new Dexie(name); if (typeof schema === 'object') { db.version(version).stores(schema); } setDatabase(db); cb && cb(db); //return () => db && db.close(); }, []); return database; } export const useDexieMonitor = (freq) => { const [data, setData] = useState({}); const intervalRef = useRef(); useEffect(() => { if (!freq) { if (intervalRef.current) clearInterval(intervalRef.current); return null; } monitor = true; intervalRef.current = setInterval(() => { const dispatch = dbDispatcher.getStats(); const tx = trxMonitor.getStats(); setData({ ...dispatch, ...tx }); }, freq); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; }, [freq]); return data; }; export const useDexieTable = (Table, ...params) => { const options = useMemo(() => (typeof params[0] === 'object' ? params[0] : emptyObj), [params]); const cb = useMemo(() => (typeof params[0] === 'object' ? params[1] : params[0]), [params]); const data = useExecuteQuery(Table, options); if (!data) return; return cb ? cb(data) : data; }; export const useDexieObj = (t, ...p) => useDataType(toObj, t, p); export const useDexieMap = (t, ...p) => useDataType(toMap, t, p); export const useDexieSet = (t, ...p) => useDataType(toSet, t, p); export function useDexieGetTable(Table, opts) { const [key, setKey] = useState(Table); const [options, setOptions] = useState(opts); const cbRef = useRef(); const data = useExecuteQuery(key, options); const func = useCallback( (...params) => { let id, opts, cb; params.forEach((param) => { if (typeof param === 'string') id = param; else if (typeof param === 'object') opts = param; else if (typeof param === 'function') cb = param; }); cbRef.current = cb; if ((id && !isEqual(id, key)) || (opts && !isEqual(opts, options))) { if (id) setKey(id); if (opts) setOptions(opts); return; } if (data && cb) return cb(data); return data; }, [key, options, data] ); useEffect(() => { if (data && cbRef.current) { cbRef.current(data); cbRef.current = undefined; } }, [data]); return func; } export function useDexieGetItem(Table, itemID, idField = 'id') { const idMap = useRef(new Set(itemID ? [itemID] : [])); const [valuesMap, setValuesMap] = useState(new Map()); const fetchValues = useCallback( async (cb) => { transaction( Table, (table) => table .where(idField) .anyOf([...idMap.current]) .toArray(), (data) => { const newValuesMap = new Map(data.map((item) => [item[idField], item])); cb && cb(newValuesMap); setValuesMap(newValuesMap); } ); }, [idField, Table] ); useSubscribeToDBChanges(Table, () => fetchValues()); const getItem = useCallback( (id, cb) => { if (!idMap.current.has(id)) { idMap.current.add(id); fetchValues((values) => { if (cb) return cb(values.get(id)); }); } else { if (cb) return cb(valuesMap.get(id)); return valuesMap.get(id); } }, [fetchValues, valuesMap] ); useEffect(() => { if (itemID && !idMap.current.has(itemID)) idMap.current.add(itemID); if (idMap.current.size > 0) fetchValues(); }, [fetchValues, itemID]); return itemID !== undefined ? valuesMap.get(itemID) : getItem; } export function useDexieGetItemKey(Table) { const getKeys = useDexieGetTable(Table); const cb = useCallback( (query, cb) => { getKeys({ ...query, primaryKeys: true, limit: 1 }, (keys) => { cb && cb(keys[0]); }); }, [getKeys] ); return cb; } export function useDexieDeleteItem(Table) { const cb = useCallback( (key, cb) => { transaction( Table, (dbTable) => dbTable.delete(key), (data) => cb && cb(data) ); }, [Table] ); return cb; } export function useDexieDeleteByQuery(Table) { const cb = useCallback( (query, cb) => { transaction( Table, (dbTable) => composeQuery(dbTable, { ...query, erase: true }), (data) => cb && cb(data) ); }, [Table] ); return cb; } export function useDexiePutItem(Table) { const cb = useCallback( (item, cb) => { transaction( Table, (dbTable) => dbTable.put(item), (data) => cb && cb(data) ); }, [Table] ); return cb; } export function useDexiePutItems(Table) { const cb = useCallback( (items, cb) => { transaction( Table, (dbTable) => dbTable.bulkPut(items), (data) => cb && cb(data) ); }, [Table] ); return cb; } export function useDexieUpdateItem(Table) { const getKey = useDexieGetItemKey(Table); const getData = useDexieGetTable(Table); const cb = useCallback( (query, cbOrItem) => { getKey(query, (key) => { getData({ ...query, limit: 1 }, (data) => { const item = data[0]; if (!item) return; const newItem = typeof cbOrItem === 'function' ? cbOrItem(item) : cbOrItem; transaction( Table, (dbTable) => { dbTable.put(newItem, key); return dbTable; }, (data) => { return data; } ); }); }); }, [Table, getData, getKey] ); return cb; }