idb.ts (8928B)
1 export interface IIdbKeyval { 2 get<T = unknown>(key: IdbKeyval.Key): Promise<T>; 3 get<T = unknown>(keys: IdbKeyval.Key[]): Promise<T[]>; 4 each<T = unknown>(): Promise<[IdbKeyval.Key, T][]>; 5 each<T = unknown>(options: IdbKeyval.IQuery): Promise<[IdbKeyval.Key, T][]>; 6 each(options: IdbKeyval.IQuery, only: "keys"): Promise<IdbKeyval.Key[]>; 7 each<T = unknown>(options: IdbKeyval.IQuery, only: "values"): Promise<T[]>; 8 set(key: IdbKeyval.Key, value: unknown): Promise<void>; 9 set(entries: [IdbKeyval.Key, unknown][]): Promise<void>; 10 delete(): Promise<void>; 11 delete(range: IDBKeyRange): Promise<void>; 12 delete(key: IdbKeyval.Key): Promise<void>; 13 delete(keys: IdbKeyval.Key[]): Promise<void>; 14 } 15 16 const is_entry_list = ( 17 value: IdbKeyval.Key | [IdbKeyval.Key, unknown][] 18 ): value is [IdbKeyval.Key, unknown][] => { 19 if (!Array.isArray(value)) return false; 20 if (value.length === 0) return true; 21 for (const entry of value) { 22 if (!Array.isArray(entry)) return false; 23 if (entry.length !== 2) return false; 24 } 25 return true; 26 }; 27 28 export class IdbKeyval implements IIdbKeyval { 29 static readonly UNBOUND = IDBKeyRange.lowerBound(Number.MIN_SAFE_INTEGER); 30 31 static prefix(prefix: string): IDBKeyRange { 32 return IDBKeyRange.bound(prefix, prefix + "\uFFFF"); 33 } 34 35 static async each(): Promise<string[]> { 36 const databases = await indexedDB.databases(); 37 return databases 38 .map((db) => db.name) 39 .filter((name): name is string => !!name && name.startsWith(this.KV_PREFIX)); 40 } 41 42 static async delete(...names: string[]): Promise<void> { 43 const resolved_names = names.length 44 ? names.map((name) => (name.startsWith(this.KV_PREFIX) ? name : this.KV_PREFIX + name)) 45 : await this.each(); 46 47 await Promise.all(resolved_names.map((name) => this.as_promise(indexedDB.deleteDatabase(name)))); 48 } 49 50 private static readonly KV_PREFIX = "radroots-web-keyval"; 51 52 constructor(options: IdbKeyval.IConstructorOptions = {}) { 53 const idx = options.indexes || []; 54 this.indexes = (Array.isArray(idx) ? idx : [idx]).sort(); 55 this.name = options.name?.toString() || IdbKeyval.KV_PREFIX; 56 } 57 58 private readonly indexes: string[]; 59 private readonly name: string; 60 61 get<T = unknown>(key: IdbKeyval.Key): Promise<T>; 62 get<T = unknown>(keys: IdbKeyval.Key[]): Promise<T[]>; 63 async get<T = unknown>(k: IdbKeyval.Key | IdbKeyval.Key[]): Promise<T | T[]> { 64 const store = await this.get_store("readonly"); 65 66 return Array.isArray(k) 67 ? Promise.all(k.map((key) => IdbKeyval.as_promise<T>(store.get(key)))) 68 : IdbKeyval.as_promise<T>(store.get(k)); 69 } 70 71 each<T = unknown>(): Promise<[IdbKeyval.Key, T][]>; 72 each<T = unknown>(options: IdbKeyval.IQuery): Promise<[IdbKeyval.Key, T][]>; 73 each(options: IdbKeyval.IQuery, only: "keys"): Promise<IdbKeyval.Key[]>; 74 each<T = unknown>(options: IdbKeyval.IQuery, only: "values"): Promise<T[]>; 75 async each<T = unknown>( 76 options: IdbKeyval.IQuery = {}, 77 only?: "keys" | "values" 78 ): Promise<[IdbKeyval.Key, T][] | IdbKeyval.Key[] | T[]> { 79 const store = await this.get_store("readonly"); 80 const target = options.index ? store.index(options.index) : store; 81 const limit = options.limit; 82 const range = options.range; 83 84 if (only === "keys") { 85 return IdbKeyval.as_promise<IdbKeyval.Key[]>(target.getAllKeys(range, limit)); 86 } 87 88 if (only === "values") { 89 return IdbKeyval.as_promise<T[]>(target.getAll(range, limit)); 90 } 91 92 const [keys, values] = await Promise.all([ 93 IdbKeyval.as_promise<IdbKeyval.Key[]>(target.getAllKeys(range, limit)), 94 IdbKeyval.as_promise<T[]>(target.getAll(range, limit)) 95 ]); 96 97 return keys.map<[IdbKeyval.Key, T]>((key, index) => [key, values[index]]); 98 } 99 100 async set(key: IdbKeyval.Key, value: unknown): Promise<void>; 101 async set(entries: [IdbKeyval.Key, unknown][]): Promise<void>; 102 async set(a: IdbKeyval.Key | [IdbKeyval.Key, unknown][], b?: unknown): Promise<void> { 103 const store = await this.get_store("readwrite"); 104 if (is_entry_list(a)) { 105 for (const [key, value] of a) { 106 store.put(value, key); 107 } 108 109 return IdbKeyval.as_promise(store.transaction); 110 } 111 112 store.put(b, a); 113 return IdbKeyval.as_promise(store.transaction); 114 } 115 116 async delete(): Promise<void>; 117 async delete(range: IDBKeyRange): Promise<void>; 118 async delete(key: IdbKeyval.Key): Promise<void>; 119 async delete(keys: IdbKeyval.Key[]): Promise<void>; 120 async delete(arg?: IdbKeyval.Key | IdbKeyval.Key[] | IDBKeyRange): Promise<void> { 121 const store = await this.get_store("readwrite"); 122 const delete_arg = arg ?? IdbKeyval.UNBOUND; 123 124 if (Array.isArray(delete_arg)) { 125 for (const key of delete_arg) { 126 store.delete(key); 127 } 128 } else { 129 store.delete(delete_arg); 130 } 131 132 return IdbKeyval.as_promise(store.transaction); 133 } 134 135 private async get_store(mode: IDBTransactionMode): Promise<IDBObjectStore> { 136 const db = await this.get_database(); 137 return db.transaction(this.name, mode).objectStore(this.name); 138 } 139 140 private async get_database(): Promise<IDBDatabase> { 141 if (!this.database) { 142 await this.maybe_fix_safari(); 143 let quit = false; 144 let version: number | undefined; 145 let index_names_added: string[] = []; 146 let index_names_removed: string[] = []; 147 148 for (;;) { 149 const request = indexedDB.open(this.name, version); 150 request.onupgradeneeded = () => { 151 const db = request.result; 152 const tx = request.transaction; 153 if (!tx) return; 154 155 const store = tx.objectStoreNames.contains(this.name) 156 ? tx.objectStore(this.name) 157 : db.createObjectStore(this.name); 158 159 for (const index of index_names_added) { 160 store.createIndex(index, index); 161 } 162 163 for (const index of index_names_removed) { 164 store.deleteIndex(index); 165 } 166 }; 167 this.database = await IdbKeyval.as_promise(request); 168 169 if (quit) { 170 break; 171 } 172 173 const tx = this.database.transaction(this.name, "readonly"); 174 const store = tx.objectStore(this.name); 175 const index_names = Array.from(store.indexNames).sort(); 176 tx.abort(); 177 178 index_names_added = this.indexes.filter((name) => !index_names.includes(name)); 179 index_names_removed = index_names.filter((name) => !this.indexes.includes(name)); 180 181 if (index_names_added.length + index_names_removed.length === 0) { 182 break; 183 } 184 185 quit = true; 186 this.database.close(); 187 version = this.database.version + 1; 188 } 189 } 190 191 return this.database; 192 } 193 194 private database: IDBDatabase | null = null; 195 196 private async maybe_fix_safari(): Promise<void> { 197 if (!/Version\/14\.\d*\s*Safari\//.test(navigator.userAgent)) { 198 return; 199 } 200 201 let id: ReturnType<typeof setInterval> | undefined; 202 await new Promise<void>((resolve) => { 203 const hit = () => indexedDB.databases().finally(resolve); 204 id = setInterval(hit, 50); 205 hit(); 206 }).finally(() => { 207 if (id) { 208 clearInterval(id); 209 } 210 }); 211 } 212 213 private static as_promise<T>(request: IDBRequest<T>): Promise<T>; 214 private static as_promise(request: IDBTransaction): Promise<void>; 215 private static as_promise<T>(request: IDBRequest<T> | IDBTransaction): Promise<T | void> { 216 return new Promise<T | void>((resolve, reject) => { 217 if ("onsuccess" in request) { 218 request.onsuccess = () => resolve(request.result); 219 request.onerror = () => reject(request.error); 220 return; 221 } 222 223 request.oncomplete = () => resolve(); 224 request.onabort = () => reject(request.error); 225 request.onerror = () => reject(request.error); 226 }); 227 } 228 } 229 230 export namespace IdbKeyval { 231 export interface IConstructorOptions { 232 name?: string | number; 233 indexes?: string | string[]; 234 } 235 236 export interface IQuery { 237 range?: IDBKeyRange; 238 index?: string; 239 limit?: number; 240 } 241 242 export type Key = IDBValidKey; 243 }