web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

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 }