Skip to content

Commit

Permalink
feat: support multiple stores per database
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche committed Oct 12, 2021
1 parent 0ac2a53 commit e40222c
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 99 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
],
"dependencies": {
"debug": "^4.3.2",
"idb": "^6.1.4",
"idb-keyval": "^6.0.2"
},
"devDependencies": {
Expand All @@ -27,7 +28,7 @@
"@types/jest": "^27.0.2",
"@types/react": "^17.0.27",
"eslint": "^7.32.0",
"fake-indexeddb": "^3.1.3",
"fake-indexeddb": "^3.1.4",
"jest": "^27.2.5",
"react": "^17.0.2",
"react-dom": "^17.0.2",
Expand Down
98 changes: 81 additions & 17 deletions src/driver/IndexedDB.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,95 @@
import { clear, createStore, del, entries, getMany, setMany } from 'idb-keyval'
import { DBDriver, DBKey, DBValue } from './abstract'
import { openDB, IDBPDatabase } from 'idb'
import { DBDriver, DBKey } from './abstract'

export default (dbName: string, storeName: string): IndexedDBDriver => new IndexedDBDriver(dbName, storeName)
export default function (
dbName: string,
storeName: string,
): DBDriver {
return new IndexedDB(() => getDBWithStore(dbName, storeName), storeName)
}

const dbs: Record<string, Promise<IDBPDatabase>> = {}

export async function resetConnections(): Promise<void> {
for(const [name, db] of Object.entries(dbs)) {
(await db).close()
delete dbs[name]
}
}

class IndexedDBDriver implements DBDriver {
constructor(dbName: string, storeName: string) {
this.store = createStore(dbName, storeName)
export async function getDBWithStore(
dbName: string,
storeName: string,
): Promise<IDBPDatabase> {
const db = await (dbs[dbName] ?? open(dbName, storeName))

if (!db.objectStoreNames.contains(storeName)) {
return open(dbName, storeName, db.version + 1)
}
store: ReturnType<typeof createStore>

clear(): Promise<void> {
return clear(this.store)
return db
}

async function open(dbName: string, storeName: string, version: number | undefined = undefined) {
if (dbName in dbs) {
(await dbs[dbName]).close()
}
return dbs[dbName] = openDB(dbName, version, {
upgrade(db) {
db.createObjectStore(storeName)
},
})
}

del(key: DBKey): Promise<void> {
return del(key, this.store)
export class IndexedDB {
private getDB: () => Promise<IDBPDatabase>
private storeName: string
constructor(getDB: () => Promise<IDBPDatabase>, storeName: string) {
this.getDB = getDB
this.storeName = storeName
}

entries<T extends unknown>(): Promise<[DBKey, NonNullable<DBValue<T>>][]> {
return entries(this.store)
async clear(): Promise<void> {
return (await this.getDB()).clear(this.storeName)
}

getMany<T extends unknown>(keys: DBKey[]): Promise<DBValue<T>[]> {
return getMany(keys, this.store)
async del(key: DBKey): Promise<void> {
return (await this.getDB()).delete(this.storeName, key)
}

setMany<T extends unknown>(entries: [DBKey, DBValue<T>][]): Promise<void> {
return setMany(entries, this.store)
async entries<T>(): Promise<Array<[IDBValidKey, T]>> {
const items: Array<[IDBValidKey, T]> = []
const transaction = (await this.getDB()).transaction(this.storeName, 'readonly')

let cursor = await transaction.store.openCursor()
while(cursor) {
items.push([cursor.key, cursor.value])
cursor = await cursor.continue()
}

await transaction.done
return items
}

async getMany<T>(keys: DBKey[]): Promise<Array<T>> {
const transaction = (await this.getDB()).transaction(this.storeName, 'readonly')

const r = await Promise.all(keys.map(k => transaction.store.get(k)))

await transaction.done
return r
}

async setMany(entries: [DBKey, unknown][]): Promise<void> {
const transaction = (await this.getDB()).transaction(this.storeName, 'readwrite')

await Promise.all<unknown>(entries.map(([key, value]) => (
value !== undefined
? transaction.store.put(value, key)
: transaction.store.delete(key)
)))

transaction.commit()
return transaction.done
}
}
31 changes: 31 additions & 0 deletions src/driver/IndexedKeyvalDB.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createStore>

clear(): Promise<void> {
return clear(this.store)
}

del(key: DBKey): Promise<void> {
return del(key, this.store)
}

entries<T extends unknown>(): Promise<[DBKey, NonNullable<DBValue<T>>][]> {
return entries(this.store)
}

getMany<T extends unknown>(keys: DBKey[]): Promise<DBValue<T>[]> {
return getMany(keys, this.store)
}

setMany<T extends unknown>(entries: [DBKey, DBValue<T>][]): Promise<void> {
return setMany(entries, this.store)
}
}
9 changes: 6 additions & 3 deletions test/_setup.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import fakeIndexDB from 'fake-indexeddb'
import 'fake-indexeddb/auto'
import FDBFactory from 'fake-indexeddb/lib/FDBFactory'
import { resetConnections } from '../src/driver/IndexedDB'

beforeEach(() => {
global.indexedDB = fakeIndexDB
beforeEach(async () => {
global.indexedDB = new FDBFactory()
await resetConnections()
})

jest.mock('debug', () => ({
Expand Down
35 changes: 35 additions & 0 deletions test/driver/DB.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import IndexedDB from '../../src/driver/IndexedDB'
import IndexedKeyvalDB from '../../src/driver/IndexedKeyvalDB'

test.each([
IndexedDB,
IndexedKeyvalDB,
])('store and retrieve data', async (driverFactory) => {
const driver = driverFactory('test', 'teststorage')

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([])
})
44 changes: 13 additions & 31 deletions test/driver/IndexedDB.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,20 @@
import { clear, createStore } from 'idb-keyval'
import IndexedDB from '../../src/driver/IndexedDB'

const storeParams: Parameters<typeof createStore> = ['test', 'teststorage']
test('use multiple stores', async () => {
const driverA = IndexedDB('test', 'teststorageA')
const driverB = IndexedDB('test', 'teststorageB')

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 driverA.setMany([
['key1', {data: 'value1', 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 driverB.setMany([
['key2', {data: 'value2', meta: {}}],
])

await expect(driver.clear()).resolves.toBe(undefined)

await expect(driver.entries()).resolves.toEqual([])
await expect(driverA.entries()).resolves.toEqual([
['key1', {data: 'value1', meta: {}}],
])
await expect(driverA.entries()).resolves.toEqual([
['key1', {data: 'value1', meta: {}}],
])
})
18 changes: 10 additions & 8 deletions test/methods/get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { setupApi } from './_'

const wait = () => new Promise(r => setTimeout(r, 10))

it('get value from cache', async () => {
const { rerender, api } = await setupApi({ cacheValues: { foo: 'bar' } })

Expand Down Expand Up @@ -133,7 +135,7 @@ it('call loader for expired (per callback) entries', async () => {
expect(fuuPromise).toBeInstanceOf(Promise)
expect(faaPromise).toBeInstanceOf(Promise)

await new Promise(r => setTimeout(r, 10))
await wait()

expect(expire).toHaveBeenNthCalledWith(1, expect.objectContaining({ data: 'bar', meta: { date: new Date('2001-02-03T04:05:06')}}))
expect(expire).toHaveBeenNthCalledWith(2, expect.objectContaining({ data: 'baz', meta: { date: new Date('2001-02-03T04:05:06')}}))
Expand Down Expand Up @@ -165,7 +167,7 @@ it('call loader for expired (per age) entries', async () => {
expect(cache.foo?.promise).toBeInstanceOf(Promise)
expect(cache.fuu?.promise).toBeInstanceOf(Promise)

await new Promise(r => setTimeout(r, 10))
await wait()

expect(loader).toBeCalledWith(['foo', 'fuu'])
})
Expand All @@ -176,18 +178,18 @@ it('skip fetching object when a promise is pending', async () => {
const loader = jest.fn(() => new Promise<void>(r => { resolveLoader = r}))

expect(api.get('foo', loader)).toEqual(undefined)
await new Promise(r => setTimeout(r, 2))
await wait()
expect(loader).toBeCalledTimes(1)

expect(api.get('foo', loader)).toEqual(undefined)
await new Promise(r => setTimeout(r, 2))
await wait()
expect(loader).toBeCalledTimes(1)

resolveLoader()
await new Promise(r => setTimeout(r, 2))
await wait()

expect(api.get('foo', loader)).toEqual(undefined)
await new Promise(r => setTimeout(r, 2))
await wait()
expect(loader).toBeCalledTimes(2)
})

Expand All @@ -201,13 +203,13 @@ it('remove promise when resolved', async () => {
expect(cache.bar.promise).toBeInstanceOf(Promise)
expect(cache.baz.promise).toBeInstanceOf(Promise)

await new Promise(r => setTimeout(r, 2))
await wait()

expect(cache.bar.promise).toBe(undefined)
expect(cache.baz.promise).toBeInstanceOf(Promise)

resolveLoader()
await new Promise(r => setTimeout(r, 2))
await wait()

expect(cache.foo.promise).toBe(undefined)
expect(cache.bar.promise).toBe(undefined)
Expand Down
Loading

0 comments on commit e40222c

Please sign in to comment.