Created
September 29, 2023 17:07
-
-
Save zerobias/8f27461e80b436e3c44fe11b685d60ad to your computer and use it in GitHub Desktop.
Indexed DB with effector
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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