Skip to content

Instantly share code, notes, and snippets.

@zerobias
Created September 29, 2023 17:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zerobias/8f27461e80b436e3c44fe11b685d60ad to your computer and use it in GitHub Desktop.
Save zerobias/8f27461e80b436e3c44fe11b685d60ad to your computer and use it in GitHub Desktop.
Indexed DB with effector
import {createEvent, Event} from 'effector'
type DOMEvent = globalThis.Event
type Deferrable<Done, Fail> = {
addEventListener(event: 'success', handler: (val: Done) => any): any
addEventListener(event: 'error', handler: (err: Fail) => any): any
}
type ObjectDB = {
name: string
version: number
upgradeNeeded: Event<{
event: IDBVersionChangeEvent
db: IDBDatabase
}>
dbError: Event<DOMEvent>
blocked: Event<DOMEvent>
initDB(): Promise<IDBDatabase>
db: IDBDatabase | null
stores: Array<ObjectStore<any, any>>
indexes: Array<ObjectStoreIndex<any, any, any>>
}
type ObjectStore<T, K extends keyof T> = {
storeName: string
key: K
autoIncrement: boolean
db: ObjectDB
indexes: Array<ObjectStoreIndex<T, any, K>>
defaultValues?: Array<T | Omit<T, K>>
}
type ObjectStoreIndex<
T,
K extends keyof T,
PK extends keyof T,
V = T[K],
PKV = T[PK]
> = {
storeName: string
indexName: string
key: K
primaryKey: PK
unique: boolean
multiEntry: boolean
objectStore: ObjectStore<T, PK>
}
export type InputObject<
T extends ObjectStore<any, any>
> = T extends ObjectStore<infer T, infer S> ? Omit<T, S> | T : never
export function createDB({
name,
version
}: {
name: string
version: number
}): ObjectDB {
const dbError = createEvent<DOMEvent>()
dbError.watch(e => {
console.error('db error', e)
})
const upgradeNeeded: Event<{
event: IDBVersionChangeEvent
db: IDBDatabase
}> = createEvent()
upgradeNeeded.watch(({event: ev, db}) => {
console.warn('upgradeneeded', ev.newVersion, ev.oldVersion, ev)
})
const blocked = createEvent<DOMEvent>()
blocked.watch(e => {
console.error('db blocked', e)
})
const result: ObjectDB = {
name,
version,
upgradeNeeded,
dbError,
blocked,
async initDB() {
if (result.db) return result.db
const request = indexedDB.open(name, version)
let needToFillDefaults = false
request.addEventListener('upgradeneeded', ev => {
const db = (ev as any).target.result as IDBDatabase
if (ev.oldVersion === 0) {
needToFillDefaults = true
initBD(db, result)
}
upgradeNeeded({event: ev, db})
})
request.addEventListener('blocked', blocked)
const e: any = await defer(request)
const db: IDBDatabase = e.target.result
db.addEventListener('error', dbError)
if (needToFillDefaults) {
await fillBD(db, result)
} else if (result.stores.some(e => !e.autoIncrement && e.defaultValues)) {
await fillBD(db, result, true)
}
result.db = db
return db
},
db: null,
stores: [],
indexes: []
}
return result
}
async function fillBD(
db: IDBDatabase,
definition: ObjectDB,
noAutoIncremented = false
) {
const names = [] as string[]
const values = {} as Record<string, any[]>
for (const storeDef of definition.stores) {
const defaults = storeDef.defaultValues
if (!defaults) continue
if (noAutoIncremented && storeDef.autoIncrement) continue
names.push(storeDef.storeName)
values[storeDef.storeName] = defaults
}
if (names.length === 0) return
const trans = db.transaction(names, 'readwrite')
await Promise.all(
names.map(name => {
const store = trans.objectStore(name)
const storeDef = definition.stores.find(def => def.storeName === name)!
return Promise.all(
values[name].map(async item => {
if (storeDef.autoIncrement) return defer(store.add(item))
try {
await defer(store.add(item))
} catch (err) {
const message = err?.target?.error?.message
if (
message === 'Key already exists in the object store.' ||
message === 'Key already exists in the object store'
) {
err.stopPropagation()
return
}
console.error(err)
}
// await defer(store.add(item, item[storeDef.key]))
})
)
})
)
}
function initBD(db: IDBDatabase, definition: ObjectDB) {
for (const storeDef of definition.stores) {
const store = db.createObjectStore(storeDef.storeName, {
keyPath: storeDef.key,
autoIncrement: storeDef.autoIncrement
})
for (const {indexName, key, unique, multiEntry} of storeDef.indexes) {
store.createIndex(indexName, key, {
unique,
multiEntry
})
}
}
}
export async function setItem<T, K extends keyof T>(
objectStore: ObjectStore<T, K>,
{
item,
override = true
}: {
item: T | Omit<T, K>
override?: boolean
}
) {
const transaction = currentTransaction!
const store = transaction.objectStore(objectStore.storeName)
const req = override ? store.put(item) : store.add(item)
return defer(req).finally(() => {
currentTransaction = transaction
})
}
type GetIndexValue<
I extends ObjectStoreIndex<any, any, any>
> = I extends ObjectStoreIndex<infer V, any, any> ? V : never
type GetIndexKey<
I extends ObjectStoreIndex<any, any, any>
> = I extends ObjectStoreIndex<infer V, infer K, any> ? V[K] : never
type GetStorageValue<I extends ObjectStore<any, any>> = I extends ObjectStore<
infer V,
any
>
? V
: never
type GetStorageKey<I extends ObjectStore<any, any>> = I extends ObjectStore<
infer V,
infer K
>
? V[K]
: never
export async function getByIndex<I extends ObjectStoreIndex<any, any, any>>(
indexDef: I,
{key}: {key: GetIndexKey<I>}
): Promise<GetIndexValue<I>> {
const transaction = currentTransaction!
const store = transaction.objectStore(indexDef.objectStore.storeName)
const index = store.index(indexDef.indexName)
return defer(index.get(key))
.then((ev: any) => {
const result = ev.target.result
if (result === undefined) throw Error('not found')
return result
})
.finally(() => {
currentTransaction = transaction
})
}
type IndexKeyIteratorValue<T extends ObjectStoreIndex<any, any, any>> = {
key: GetIndexKey<T>
primaryKey: T extends ObjectStoreIndex<infer Val, any, infer PK>
? Val[PK]
: never
}
export async function* indexKeyIterator<
T extends ObjectStoreIndex<any, any, any>
>(
indexDef: T,
direction: 'next' | 'nextunique' | 'prev' | 'prevunique' = 'next'
) {
const transaction = currentTransaction!
const store = transaction.objectStore(indexDef.objectStore.storeName)
const index = store.index(indexDef.indexName)
const keyCursor = index.openKeyCursor(null, direction)
let defer = new Defer<IndexKeyIteratorValue<T> | null, any>()
let iterValue: IndexKeyIteratorValue<T> | null = null
defer.req.finally(() => {
currentTransaction = transaction
})
keyCursor.addEventListener('success', (ev: any) => {
const currentDefer = defer
defer = new Defer()
defer.req.finally(() => {
currentTransaction = transaction
})
const cursor = ev.target.result
if (!cursor) {
currentDefer.rs(null)
} else {
currentDefer.rs({
key: cursor.key,
primaryKey: cursor.primaryKey
})
cursor.continue()
}
})
keyCursor.addEventListener('error', (ev: any) => {
const currentDefer = defer
defer = new Defer()
defer.req.finally(() => {
currentTransaction = transaction
})
currentDefer.rj(ev)
})
while ((iterValue = await defer.req)) {
yield iterValue
}
}
type GetPK<
Shape extends Record<string, ObjectStoreIndex<any, any, any>>
> = Shape extends Record<string, ObjectStoreIndex<infer T, any, infer PK>>
? Pick<T, PK>
: never
export async function readIndexes<
Shape extends Record<string, ObjectStoreIndex<any, any, any>>
>(
shape: Shape
): Promise<
Array<
{
[K in keyof Shape]: GetIndexKey<Shape[K]>
} &
GetPK<Shape>
>
> {
const reqs = [] as Promise<void>[]
const shapeIndex = {} as Record<any, any>
for (const field in shape) {
const index = shape[field]
const req = (async () => {
for await (const {key, primaryKey} of indexKeyIterator(index)) {
if (!shapeIndex[primaryKey]) {
shapeIndex[primaryKey] = {
id: primaryKey,
[field]: key
}
} else {
shapeIndex[primaryKey][field] = key
}
}
})()
reqs.push(req)
}
await Promise.all(reqs)
return Object.values(shapeIndex)
}
export async function getAllKeys<T extends ObjectStore<any, any>>(
objectStore: T
): Promise<Array<GetStorageKey<T>>> {
const transaction = currentTransaction!
const store = transaction.objectStore(objectStore.storeName)
return defer(store.getAllKeys())
.then((ev: any) => {
return ev.target.result
})
.finally(() => {
currentTransaction = transaction
})
}
export async function getAllIndexKeys<
T extends ObjectStoreIndex<any, any, any>
>(indexDef: T): Promise<Array<GetIndexKey<T>>> {
const transaction = currentTransaction!
const store = transaction.objectStore(indexDef.objectStore.storeName)
const index = store.index(indexDef.indexName)
return defer(index.getAllKeys())
.then((ev: any) => {
console.log(ev)
return ev.target.result
})
.finally(() => {
currentTransaction = transaction
})
}
export async function getItem<T extends ObjectStore<any, any>>(
objectStore: T,
id: GetStorageKey<T>
): Promise<GetStorageValue<T>> {
const transaction = currentTransaction!
const store = transaction.objectStore(objectStore.storeName)
return defer(store.get(id))
.then((ev: any) => {
const result = ev.target.result
if (result === undefined) throw Error('not found')
return result
})
.finally(() => {
currentTransaction = transaction
})
}
export function createObjectStore<T, K extends keyof T>({
name,
key,
db,
autoIncrement = false,
defaultValues
}: {
name: string
key: K
db: ObjectDB
autoIncrement?: boolean
defaultValues?: Array<T | Omit<T, K>>
}): ObjectStore<T, K> {
const result = {
storeName: name,
key,
autoIncrement,
db,
indexes: [],
defaultValues
}
db.stores.push(result)
return result
}
export function createIndex<T, K extends keyof T, PK extends keyof T>(
objectStore: ObjectStore<T, PK>,
{
name,
key,
unique = false,
multiEntry = false
}: {name: string; key: K; unique?: boolean; multiEntry?: boolean}
): ObjectStoreIndex<T, K, PK> {
const {storeName, key: primaryKey, db, indexes} = objectStore
const result = {
storeName,
indexName: name,
key,
primaryKey,
unique,
multiEntry,
objectStore
}
indexes.push(result)
db.indexes.push(result)
return result
}
export function createTransaction<T, S>({
stores = [],
indexes = [],
readonly = true,
handler
}: {
stores?: Array<ObjectStore<any, any>>
indexes?: Array<ObjectStoreIndex<any, any, any>>
readonly?: boolean
handler: (data: T) => Promise<S>
}) {
const mode = readonly ? 'readonly' : 'readwrite'
if (stores.length === 0 && indexes.length === 0) {
throw Error('either stores or indexes must exists')
}
let objectStore: ObjectDB
if (stores.length > 0) {
objectStore = stores[0].db
} else {
objectStore = indexes[0].objectStore.db
}
const storeNamesOnly = stores.map(({storeName}) => storeName)
const storeNamesByIndexes = indexes.map(({storeName}) => storeName)
const storeNames = [...new Set([...storeNamesOnly, ...storeNamesByIndexes])]
return async (data: T): Promise<S> => {
let db = objectStore.db
if (!db) db = await objectStore.initDB()
const transaction = db.transaction(storeNames, mode)
currentTransaction = transaction
const result = await handler(data)
return deferTransaction(transaction)
.then(() => result)
.finally(() => {
currentTransaction = null
})
}
}
let currentTransaction: IDBTransaction | null
function deferTransaction(transaction: IDBTransaction, throwOnAbort = true) {
const req = new Defer<globalThis.Event, globalThis.Event>()
transaction.addEventListener('complete', req.rs)
transaction.addEventListener('error', e => {
e.preventDefault()
req.rj(e)
})
if (throwOnAbort) {
transaction.addEventListener('abort', req.rj)
}
return req.req
}
function defer<Done, Fail>(parent?: Deferrable<Done, Fail>): Promise<Done> {
return new Defer(parent).req
}
class Defer<Done, Fail> {
rs: (val: Done) => void
rj: (err: Fail) => void
req: Promise<Done>
constructor(parent?: Deferrable<Done, Fail>) {
this.req = new Promise((rs, rj) => {
this.rs = rs
this.rj = rj
})
if (parent) {
parent.addEventListener('success', this.rs)
parent.addEventListener('error', this.rj)
}
}
}
import {attach, createEffect, createStore} from 'effector'
import {
createDB,
createObjectStore,
createIndex,
createTransaction,
getByIndex,
getItem,
setItem,
InputObject
} from './db'
export type Config = {
id: string
selectedProject: string | null
selectedFile: string | null
}
const defaultConfig: Config = {
id: 'config',
selectedProject: null,
selectedFile: null
}
const configDB = createDB({
name: 'storagePanelConfig',
version: 1
})
const configStore = createObjectStore<Config, 'id'>({
name: 'config',
key: 'id',
autoIncrement: false,
db: configDB,
defaultValues: [defaultConfig]
})
const readConfig = createEffect(
createTransaction<void, Config>({
stores: [configStore],
handler: () => getItem(configStore, 'config')
})
)
const writeConfig = createEffect(
createTransaction({
stores: [configStore],
readonly: false,
handler(config: Config | Omit<Config, 'id'>) {
return setItem(configStore, {
override: true,
item: {
...config,
id: 'config'
}
})
}
})
)
export const config = createStore(defaultConfig)
.on(readConfig.doneData, (_, config) => config)
.on(writeConfig.done, (_, {params: config}) => ({
...config,
id: 'config'
}))
export const selectedFile = config.map(({selectedFile}) => selectedFile)
export const configInitialized = createStore(false).on(
readConfig.doneData,
() => true
)
export const selectFile = attach({
source: config,
effect: writeConfig,
mapParams: (file: string, config) => ({...config, selectedFile: file})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment