diff --git a/README.md b/README.md index dd950be..8b80fba 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,25 @@ You can also make a component use its own local cache: const api = useCached({context: false}) ``` +#### DBDriver + +This library defaults to use [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) as device level cache per `idb-keyval`. + +You can switch out the driver used for local cache in the context of a `CacheProvider`. + +```js +import { CacheProvider } from 'react-idb-cache' +import NullDB from 'react-idb-cache/driver/NullDB' + +function MyWrapperComponent() { + return + + +} +``` + +> Supplying your own driver is possible but considered experimental as the `DBDriver` API might be subject to change in `minor` versions. If you'd like to add another local DB implementation, PRs are very much welcome! + ### Get #### Get a single value diff --git a/package.json b/package.json index 9d73419..4b707ba 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,35 @@ "files": [ "/dist" ], + "exports": { + ".": { + "node": "./dist/cjs/index.js", + "import": "./dist/esm/index.js", + "default": "./dist/cjs/index.js" + }, + "./dist/*": "./dist/*.js", + "./driver/*": { + "node": "./dist/cjs/driver/*.js", + "import": "./dist/esm/driver/*.js", + "default": "./dist/cjs/driver/*.js" + } + }, + "typesVersions": { + "*": { + "dist/types/*": [ + "./dist/types/*" + ], + "dist/cjs/*": [ + "./dist/types/*.d.ts" + ], + "dist/esm/*": [ + "./dist/types/*.d.ts" + ], + "*": [ + "./dist/types/*.d.ts" + ] + } + }, "dependencies": { "debug": "^4.3.2", "idb-keyval": "^5.1.3" diff --git a/src/context.ts b/src/context.ts deleted file mode 100644 index a09bfad..0000000 --- a/src/context.ts +++ /dev/null @@ -1,17 +0,0 @@ -import React, { useRef } from 'react' -import { reactCache } from './shared' - -type globalCache = { - [dbName: string]: { - [storeName: string]: reactCache, - }, -} - -export const CacheContext = React.createContext({}) - -export function CacheProvider( - {children}: React.PropsWithChildren, -): React.ReactElement { - const cache = useRef({}).current - return React.createElement(CacheContext.Provider, {value: cache}, children) -} diff --git a/src/context.tsx b/src/context.tsx new file mode 100644 index 0000000..6418882 --- /dev/null +++ b/src/context.tsx @@ -0,0 +1,53 @@ +import React, { useMemo, useRef } from 'react' +import { reactCache } from './shared' +import { DBDriverFactory } from './driver/abstract' +import { lazyDBDriver } from './driver/lazy' + +/** + * @internal + */ +type CacheContext = { + cache: { + [dbName: string]: { + [storeName: string]: reactCache, + }, + }, + dbDriverFactory: DBDriverFactory, +} + +const lazyIndexedDB = lazyDBDriver(() => import('./driver/IndexedDB')) + +export const globalContext: CacheContext = { + cache: {}, + dbDriverFactory: lazyIndexedDB, +} + +export const CacheContext = React.createContext(globalContext) + +/** + * Configure the cache used outside of CacheProvider. + * Be aware that changing the global config does not rerender any components. + */ +export function configureGlobalCache( + { + dbDriverFactory, + }: { + dbDriverFactory: DBDriverFactory, + }, +): void { + globalContext.dbDriverFactory = dbDriverFactory +} + +export function CacheProvider( + { + dbDriverFactory = lazyIndexedDB, + children, + }: React.PropsWithChildren<{ + dbDriverFactory?: DBDriverFactory + }>, +): React.ReactElement { + const cache = useRef({}).current + const context: CacheContext = useMemo(() => ({cache, dbDriverFactory}), [cache, dbDriverFactory]) + + return {children} +} diff --git a/src/driver/IndexedDB.ts b/src/driver/IndexedDB.ts new file mode 100644 index 0000000..7c3bea6 --- /dev/null +++ b/src/driver/IndexedDB.ts @@ -0,0 +1,31 @@ +import { clear, createStore, del, entries, getMany, setMany } from 'idb-keyval'; +import { DBDriver, DBKey, DBValue } from './abstract'; + +export default (dbName: string, storeName: string): IndexedDBDriver => new IndexedDBDriver(dbName, storeName) + +class IndexedDBDriver implements DBDriver { + constructor(dbName: string, storeName: string) { + this.store = createStore(dbName, storeName) + } + store: ReturnType + + clear(): Promise { + return clear(this.store) + } + + del(key: DBKey): Promise { + return del(key, this.store) + } + + entries(): Promise<[DBKey, NonNullable>][]> { + return entries(this.store) + } + + getMany(keys: DBKey[]): Promise[]> { + return getMany(keys, this.store) + } + + setMany(entries: [DBKey, DBValue][]): Promise { + return setMany(entries, this.store) + } +} diff --git a/src/driver/NullDB.ts b/src/driver/NullDB.ts new file mode 100644 index 0000000..f8ec360 --- /dev/null +++ b/src/driver/NullDB.ts @@ -0,0 +1,21 @@ +import { DBDriver, DBKey, DBValue } from './abstract'; + +export default (): typeof NullDBDriver => NullDBDriver + +const NullDBDriver: DBDriver = { + async clear(): Promise { + return + }, + async del(): Promise { + return + }, + async entries(): Promise<[DBKey, NonNullable>][]> { + return [] + }, + async getMany(keys: DBKey[]): Promise[]> { + return keys.map(() => undefined) + }, + async setMany(): Promise { + return + }, +} diff --git a/src/driver/abstract.ts b/src/driver/abstract.ts new file mode 100644 index 0000000..ca6d51e --- /dev/null +++ b/src/driver/abstract.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { cachedObj } from '../shared' + +export type DBKey = IDBValidKey +export type DBValue = cachedObj | undefined + +/** + * @internal + */ +export interface DBDriverFactory { + (dbName: string, storeName: string): DBDriver +} + +/** + * Driver for local/device/session cache. + * + * @internal + */ +export interface DBDriver { + clear(): Promise + del(key: DBKey): Promise + entries(): Promise<[DBKey, NonNullable>][]> + getMany(keys: DBKey[]): Promise[]> + setMany(entries: [DBKey, DBValue][]): Promise +} diff --git a/src/driver/lazy.ts b/src/driver/lazy.ts new file mode 100644 index 0000000..cb1db8a --- /dev/null +++ b/src/driver/lazy.ts @@ -0,0 +1,44 @@ +import { DBDriver, DBDriverFactory, DBKey, DBValue } from './abstract' + +export function lazyDBDriver(loader: () => Promise): DBDriverFactory { + return (dbName, storeName) => new LazyDB(loader, dbName, storeName) +} + +class LazyDB implements DBDriver { + constructor( + loader: () => Promise, + dbName: string, + storeName: string, + ) { + this.loader = loader + this.dbName = dbName + this.storeName = storeName + } + private loader: () => Promise + private driver?: DBDriver + private dbName: string + private storeName: string + + private async loadDriver() { + return this.driver ?? this.loader().then(m => { + const factory = typeof m === 'function' ? m : m.default + return this.driver = factory(this.dbName, this.storeName) + }) + } + + async clear(): Promise { + return this.loadDriver().then(d => d.clear()) + } + async del(key: DBKey): Promise { + return this.loadDriver().then(d => d.del(key)) + } + async entries(): Promise<[DBKey, NonNullable>][]> { + return this.loadDriver().then(d => d.entries()) + } + async getMany(keys: DBKey[]): Promise[]> { + return this.loadDriver().then(d => d.getMany(keys)) + } + async setMany(entries: [DBKey, DBValue][]): Promise { + return this.loadDriver().then(d => d.setMany(entries)) + } +} diff --git a/src/methods/clear.ts b/src/methods/clear.ts index 52716d4..97cf3bb 100644 --- a/src/methods/clear.ts +++ b/src/methods/clear.ts @@ -1,20 +1,19 @@ -import { entries, setMany, clear as idbClear } from 'idb-keyval' +import { DBDriver } from '../driver/abstract' import { delProperty, dispatch, expire, reactCache, verifyEntry } from '../shared' export async function clear( cache: reactCache, - store: Parameters[0], + driver: DBDriver, expire?: expire, ): Promise { await (expire - ? entries(store) - .then(entries => setMany( + ? driver.entries() + .then(entries => driver.setMany( entries .filter(([, obj]) => !verifyEntry({ obj }, expire)) .map(([key]) => [key, undefined]), - store, )) - : idbClear(store) + : driver.clear() ) const keys: string[] = [] diff --git a/src/methods/del.ts b/src/methods/del.ts index 51f960c..3ad41bd 100644 --- a/src/methods/del.ts +++ b/src/methods/del.ts @@ -1,12 +1,12 @@ -import { del as idbDel } from 'idb-keyval' +import { DBDriver } from '../driver/abstract' import { delProperty, dispatch, reactCache } from '../shared' export async function del( cache: reactCache, - store: Parameters[1], + driver: DBDriver, key: string, ): Promise { - await idbDel(key, store) + await driver.del(key) delProperty(cache, [key, 'obj']) dispatch(cache, [key]) diff --git a/src/methods/get.ts b/src/methods/get.ts index dab0c20..248b7ec 100644 --- a/src/methods/get.ts +++ b/src/methods/get.ts @@ -1,4 +1,4 @@ -import { getMany } from 'idb-keyval' +import { DBDriver } from '../driver/abstract' import { addListener, cachedObj, debugLog, dispatch, expire, reactCache, setProperty, verifyEntry } from '../shared' type returnTypes = { @@ -24,7 +24,7 @@ export function get< T extends unknown = unknown, >( cache: reactCache, - store: Parameters[1], + driver: DBDriver, id: string, rerender: () => void, keyOrKeys: K, @@ -38,7 +38,7 @@ export function get< T extends unknown = unknown, >( cache: reactCache, - store: Parameters[1], + driver: DBDriver, id: string, rerender: () => void, keyOrKeys: K, @@ -53,7 +53,7 @@ export function get< T extends unknown = unknown, >( cache: reactCache, - store: Parameters[1], + driver: DBDriver, id: string, rerender: () => void, keyOrKeys: K, @@ -83,7 +83,7 @@ export function get< if (missing.length) { debugLog('Get from idb: %s', missing.join(', ')) - const idbPromise = getMany(missing, store).then( + const idbPromise = driver.getMany(missing).then( obj => { debugLog.enabled && debugLog( ' -> received from idb:\n%s', diff --git a/src/methods/index.ts b/src/methods/index.ts index 7bd2e24..d55e174 100644 --- a/src/methods/index.ts +++ b/src/methods/index.ts @@ -1,4 +1,4 @@ -import { createStore } from 'idb-keyval'; +import { DBDriver } from '../driver/abstract'; import { cachedObj, expire, options, reactCache } from '../shared'; import { clear } from './clear' import { del } from './del' @@ -45,11 +45,11 @@ interface cachedApi { set: typeof boundSet, } -export function createApi(cache: reactCache, store: ReturnType, id: string, rerender: () => void): cachedApi { +export function createApi(cache: reactCache, driver: DBDriver, id: string, rerender: () => void): cachedApi { return { - clear: clear.bind(undefined, cache, store) as typeof boundClear, - del: del.bind(undefined, cache, store) as typeof boundDel, - get: get.bind(undefined, cache, store, id, rerender) as typeof boundGet, - set: set.bind(undefined, cache, store) as typeof boundSet, + clear: clear.bind(undefined, cache, driver) as typeof boundClear, + del: del.bind(undefined, cache, driver) as typeof boundDel, + get: get.bind(undefined, cache, driver, id, rerender) as typeof boundGet, + set: set.bind(undefined, cache, driver) as typeof boundSet, } } diff --git a/src/methods/set.ts b/src/methods/set.ts index 5e0c55a..51dbd9e 100644 --- a/src/methods/set.ts +++ b/src/methods/set.ts @@ -1,16 +1,16 @@ -import { setMany } from 'idb-keyval' +import { DBDriver } from '../driver/abstract' import { cachedObj, delProperty, dispatch, options, reactCache, setProperty } from '../shared' export function set( cache: reactCache, - store: Parameters[1], + driver: DBDriver, recordData: Record, recordMeta?: Record, options?: options, ): Promise; export function set( cache: reactCache, - store: Parameters[1], + driver: DBDriver, key: string, data: cachedObj['data'], meta?: cachedObj['meta'], @@ -18,20 +18,20 @@ export function set( ): Promise; export async function set( cache: reactCache, - store: Parameters[1], + driver: DBDriver, ...args: unknown[] ): Promise { if (typeof args[0] == 'string') { return _set( cache, - store, + driver, { [args[0]]: { data: args[1], meta: args[2] } } as Record, args[3] as options, ) } else { return _set( cache, - store, + driver, Object.assign({}, ...Object .entries(args[0] as Record) .map(([key, data]) => { @@ -49,7 +49,7 @@ export async function set( async function _set( cache: reactCache, - store: Parameters[1], + driver: DBDriver, record: Record, options: options = {}, ): Promise { @@ -68,7 +68,7 @@ async function _set( }) if (!options.skipIdb) { - await setMany(entries, store) + await driver.setMany(entries) } entries.forEach(([key, obj]) => { diff --git a/src/useCached.ts b/src/useCached.ts index 1bc29cd..cb55c51 100644 --- a/src/useCached.ts +++ b/src/useCached.ts @@ -1,5 +1,4 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { createStore } from 'idb-keyval' import { addListener, reactCache, removeListener } from './shared' import { createApi } from './methods' import { CacheContext } from './context' @@ -9,10 +8,8 @@ export function useCached({dbName = 'Cached', storeName = 'keyval', context = tr storeName?: string, context?: boolean, } = {}): ReturnType { - const store = useRef(createStore(dbName, storeName)).current - + const { cache: contextCache, dbDriverFactory } = useContext(CacheContext) const componentCache = useRef({}).current - const contextCache = useContext(CacheContext) if (context) { if (!contextCache[dbName]) { @@ -36,7 +33,10 @@ export function useCached({dbName = 'Cached', storeName = 'keyval', context = tr } }) - const api = useMemo(() => createApi(cache, store, id, rerender), [cache, store, id, rerender]) + const api = useMemo( + () => createApi(cache, dbDriverFactory(dbName, storeName), id, rerender), + [cache, dbDriverFactory, dbName, storeName, id, rerender], + ) return api } diff --git a/test/context.tsx b/test/context.tsx index 9e7e476..b526d2b 100644 --- a/test/context.tsx +++ b/test/context.tsx @@ -1,10 +1,20 @@ import { render } from '@testing-library/react' import React, { useContext, useEffect } from 'react' -import { CacheContext, CacheProvider } from '../src/context' +import { CacheContext, CacheProvider, configureGlobalCache, globalContext } from '../src/context' +import NullDB from '../src/driver/NullDB' + +let defaultContext: typeof globalContext +beforeAll(() => { + defaultContext = {...globalContext} +}) +afterEach(() => { + globalContext.cache = {} + globalContext.dbDriverFactory = defaultContext.dbDriverFactory +}) it('provide cache context', async () => { function TestComponentA() { - const cache = useContext(CacheContext) + const {cache} = useContext(CacheContext) useEffect(() => { cache['foo'] = {} cache['foo']['bar'] = {} @@ -15,7 +25,7 @@ it('provide cache context', async () => { let valueB = undefined function TestComponentB() { - const cache = useContext(CacheContext) + const {cache} = useContext(CacheContext) useEffect(() => { valueB = cache.foo?.bar?.baz?.obj?.data }) @@ -24,7 +34,7 @@ it('provide cache context', async () => { let valueC = undefined function TestComponentC() { - const cache = useContext(CacheContext) + const {cache} = useContext(CacheContext) useEffect(() => { valueC = cache.foo?.bar?.baz?.obj?.data }) @@ -44,3 +54,35 @@ it('provide cache context', async () => { expect(valueB).toBe('value') expect(valueC).toBe(undefined) }) + +it('provide dbDriverFactory', () => { + let contextFactory = undefined + function TestComponent() { + const {dbDriverFactory} = useContext(CacheContext) + contextFactory = dbDriverFactory + return null + } + + render(<> + + + + ) + + expect(contextFactory).toBe(NullDB) +}) + +it('configure global dbDriverFactory', () => { + let contextFactory = undefined + function TestComponent() { + const { dbDriverFactory } = useContext(CacheContext) + contextFactory = dbDriverFactory + return null + } + + configureGlobalCache({dbDriverFactory: NullDB}) + + render() + + expect(contextFactory).toBe(NullDB) +}) diff --git a/test/driver/IndexedDB.ts b/test/driver/IndexedDB.ts new file mode 100644 index 0000000..61b14a0 --- /dev/null +++ b/test/driver/IndexedDB.ts @@ -0,0 +1,38 @@ +import { clear, createStore } from 'idb-keyval' +import IndexedDB from '../../src/driver/IndexedDB' + +const storeParams: Parameters = ['test', 'teststorage'] + +beforeEach(async () => { + await clear(createStore(...storeParams)) +}) + +test('relay calls to idb-keyval', async () => { + const driver = IndexedDB(...storeParams) + + await expect(driver.setMany([ + ['foo', { data: 'someValue', meta: {} }], + ['bar', { data: 'anotherValue', meta: {} }], + ])).resolves.toBe(undefined) + + await expect(driver.getMany(['bar', 'foo'])).resolves.toEqual([ + { data: 'anotherValue', meta: {} }, + { data: 'someValue', meta: {} }, + ]) + + await expect(driver.entries()).resolves.toEqual(expect.arrayContaining([ + ['foo', { data: 'someValue', meta: {} }], + ['bar', { data: 'anotherValue', meta: {} }], + ])) + + await expect(driver.del('foo')).resolves.toBe(undefined) + + await expect(driver.getMany(['bar', 'foo'])).resolves.toEqual([ + { data: 'anotherValue', meta: {} }, + undefined, + ]) + + await expect(driver.clear()).resolves.toBe(undefined) + + await expect(driver.entries()).resolves.toEqual([]) +}) diff --git a/test/driver/NullDB.ts b/test/driver/NullDB.ts new file mode 100644 index 0000000..5a3dece --- /dev/null +++ b/test/driver/NullDB.ts @@ -0,0 +1,15 @@ +import NullDB from '../../src/driver/NullDB' + +test('provide DBDriver api without storage', async () => { + const driver = NullDB() + + await expect(driver.setMany([ + ['foo', {data: 'someValue', meta: {}}], + ['bar', {data: 'anotherValue', meta: {}}], + ])).resolves.toBe(undefined) + await expect(driver.getMany(['foo', 'bar'])).resolves.toEqual([undefined, undefined]) + await expect(driver.entries()).resolves.toEqual([]) + + await expect(driver.del('foo')).resolves.toBe(undefined) + await expect(driver.clear()).resolves.toBe(undefined) +}) diff --git a/test/driver/lazy.ts b/test/driver/lazy.ts new file mode 100644 index 0000000..29b7711 --- /dev/null +++ b/test/driver/lazy.ts @@ -0,0 +1,51 @@ +import { cachedObj } from '../../src' +import { DBDriver } from '../../src/driver/abstract' +import { lazyDBDriver } from '../../src/driver/lazy' + + +test('load driver and relay calls', async () => { + const driver: DBDriver = { + clear: jest.fn(), + del: jest.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entries: jest.fn(async (): Promise<[string, cachedObj][]> => [ + ['foo', { data: 'bar', meta: {} }], + ]), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getMany: jest.fn(async (): Promise => [ + { data: 123, meta: {} }, + ]), + setMany: jest.fn(), + } + const loader = jest.fn(async () => (() => ({...driver}))) + const lazyFactory = lazyDBDriver(loader) + const LazyDB = lazyFactory('test', 'null') + + expect(loader).not.toBeCalled() + + await expect(LazyDB.clear()).resolves.toBe(undefined) + expect(driver.clear).toBeCalledTimes(1) + + expect(loader).toBeCalledTimes(1) + + await expect(LazyDB.del('foo')).resolves.toBe(undefined) + expect(driver.del).toBeCalledTimes(1) + expect(driver.del).toBeCalledWith('foo') + + await expect(LazyDB.entries()).resolves.toEqual([ + ['foo', { data: 'bar', meta: {} }], + ]) + expect(driver.entries).toBeCalledTimes(1) + + await expect(LazyDB.getMany(['whatever'])).resolves.toEqual([ + { data: 123, meta: {} }, + ]) + expect(driver.getMany).toBeCalledTimes(1) + expect(driver.getMany).toBeCalledWith(['whatever']) + + await expect(LazyDB.setMany([['foo', {data: 456, meta: {}}]])).resolves.toBe(undefined) + expect(driver.setMany).toBeCalledTimes(1) + expect(driver.setMany).toBeCalledWith([['foo', { data: 456, meta: {} }]]) + + expect(loader).toBeCalledTimes(1) +}) diff --git a/test/methods/_.ts b/test/methods/_.ts index 5a5caac..fc4f5f9 100644 --- a/test/methods/_.ts +++ b/test/methods/_.ts @@ -1,4 +1,5 @@ import { clear, createStore, setMany } from 'idb-keyval'; +import IndexedDB from '../../src/driver/IndexedDB'; import { createApi } from '../../src/methods'; import { addListener, cachedObj, reactCache, reactCacheEntry } from '../../src/shared'; @@ -49,7 +50,7 @@ export async function setupApi({cache: reactCache, cacheEntries, cacheObjects, c addListener(cache, listen, listenerId, listener) } - const api = createApi(cache, store, 'testComponent', rerender) + const api = createApi(cache, IndexedDB('test', 'teststore'), 'testComponent', rerender) return { cache: cache as reactCache, diff --git a/test/useCached.tsx b/test/useCached.tsx index e8d779b..e1da844 100644 --- a/test/useCached.tsx +++ b/test/useCached.tsx @@ -29,7 +29,7 @@ async function setup( await clear(store) } function ClearComponent() { - const cache = useContext(CacheContext) + const {cache} = useContext(CacheContext) Object.keys(cache).forEach(k => { delete cache[k] }) return null }