Skip to content

Instantly share code, notes, and snippets.

@MaxMonteil
Created July 5, 2023 06:34
Show Gist options
  • Save MaxMonteil/fb3bb1597905de796507fe3486993b9a to your computer and use it in GitHub Desktop.
Save MaxMonteil/fb3bb1597905de796507fe3486993b9a to your computer and use it in GitHub Desktop.
IndexedDB with multiple stores
import { openDB, IDBPDatabase } from 'idb'
export interface PersistenceFacade<T> {
exists(id: string): Promise<boolean>
save(key: string, value: T): Promise<void>
bulkSave?: (data: { key: string; val: T }[]) => Promise<void>
getByID(id: string): Promise<T | null>
getAll(): Promise<T[]>
getAllWithQuery(constraints: any): Promise<T[]>
deleteByID(id: string): Promise<void>
clear(): Promise<void>
}
export class IndexedDB<T> implements PersistenceFacade<T> {
static _version = 1
static _freshStart = true
static _checking = false
static _dbCheck: Promise<void> = Promise.resolve()
static _stores = new Set()
/**
* When the page gets reloaded, we need to update the static _version variable,
* whether the db has stores or not, this ensures we try to open with the correct version.
* This does result in db versions starting at 1, not 0 due to that initial db open.
*
* @param dbName Name of the entire local database.
*/
_checkDbVersion(dbName: string): Promise<void> {
IndexedDB._checking = true
return new Promise((resolve) => {
openDB(dbName, undefined).then((db) => {
IndexedDB._stores = new Set(db.objectStoreNames)
// version should be the number of stores +1 to account for this initial check
IndexedDB._version = db.objectStoreNames.length + 1
db.close() // always close db
IndexedDB._freshStart = false
IndexedDB._checking = false
resolve()
})
})
}
dbName: string
store: string
options: any
/**
* @param dbName Name of the entire database.
* @param store Name of the IndexedDB store.
* @param options Name of the IndexedDB store.
*/
private constructor(dbName: string, store: string, options = {}) {
/** @private */ this.dbName = dbName
/** @private */ this.store = store
/** @private */ this.options = options
if (IndexedDB._freshStart && !IndexedDB._checking) {
IndexedDB._dbCheck = this._checkDbVersion(this.dbName)
}
}
/**
* Create a new instance of IndexedDB local store.
*
* @param store Name of the IndexedDB store.
* @param dbName Name of the entire database.
* @param options Name of the IndexedDB store.
*/
static create(store: string, dbName: string, options = {}) {
return new IndexedDB(dbName, store, options)
}
/**
* Stores are actually created here instead of in the constructor,
* this prevents random stores from being created right at app launch when the user might not even be logged in
*
* @param Name of the store to get.
*/
async getStore(store = this.store) {
await IndexedDB._dbCheck
if (IndexedDB._stores.has(store)) {
return openDB(this.dbName, undefined)
}
IndexedDB._stores.add(store)
IndexedDB._version = IndexedDB._stores.size + 1
return openDB(this.dbName, IndexedDB._version, {
upgrade: (db) => db.createObjectStore(store),
})
}
/**
* Ensure all db functions close the db before next operation.
*
* @param func Database function to call
* @param args Arguments to pass to the function
*/
async _autoclose(
func: 'get' | 'getAll' | 'getAllKeys' | 'put' | 'delete' | 'clear',
...args: Parameters<IDBPDatabase[typeof func]>
) {
const db = await this.getStore()
// @ts-expect-error cannot spread the arguments
const r = await db[func](...args)
// always close db
db.close()
return r
}
/**
* Check if an item with the given id exists.
*
* @param id ID of the item to check.
*/
async exists(id: string): Promise<boolean> {
const keys = await this._autoclose('getAllKeys', this.store)
return keys.includes(id) ?? false
}
/**
* Save an item to the IndexedDB store.
*
* @param key Key by which to store the value.
* @param val The value to store in the database.
*/
async save(key: string, val: T) {
await this._autoclose('put', this.store, val, key)
}
/**
* Save multiple items to the IndexedDB store.
*
* @param data An array of key values to store.
*/
async bulkSave(data: { key: string; val: T }[]) {
const db = await this.getStore()
const tx = db.transaction(this.store, 'readwrite')
await Promise.all([...data.map(({ key, val }) => tx.store.put(val, key))])
await tx.done
db.close()
}
/**
* Get a single item by ID.
*
* @param id ID of the item to get.
*/
async getByID(id: string): Promise<T | null> {
return (await this._autoclose('get', this.store, id)) ?? null
}
/** Get all the values in the store. */
async getAll(): Promise<T[]> {
return await this._autoclose('getAll', this.store)
}
/** Get all the values in the store. */
async getAllWithQuery() {
return await this.getAll()
}
/**
* Delete an item from the database by ID.
* @param id ID of the item to delete.
*/
async deleteByID(id: string) {
return await this._autoclose('delete', this.store, id)
}
/** Delete all the items in the store. */
async clear() {
return await this._autoclose('clear', this.store)
}
}
@MaxMonteil
Copy link
Author

Made this because I needed a way to have multiple stores in IndexedDB. Haven't looked into it since but I remember it not being possible or at least not the use case for versions. It was mostly for migrations.

Using the excellent idb, this wrapper lets you create multiple stores for various different kinds of data, similar to tables in relational databases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment