Created
September 24, 2025 13:27
-
-
Save faiwer/4c38c763557a676e770498456f2c2bc6 to your computer and use it in GitHub Desktop.
idb.ts
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 debounce from 'lodash-es/debounce'; | |
| import { | |
| catchError, | |
| combineLatest, | |
| defer, | |
| filter, | |
| map, | |
| Observable, | |
| of, | |
| shareReplay, | |
| switchMap, | |
| take, | |
| tap, | |
| } from 'rxjs'; | |
| import { trackHandledError } from '@cu/common-track'; | |
| import { isEqual } from '@cu/perf-utils'; | |
| import { distinctUntilChangedDeep } from '@cu/rxjs-utils'; | |
| import type { AppSubscriptions } from '../../app-subscriptions/AppSubscriptions.interface'; | |
| export class IdbStoreCache<T> { | |
| constructor( | |
| private store: AppSubscriptions['store'], | |
| private dbName: string, | |
| private storeName: string, | |
| ) {} | |
| private idleId: number | null = null; | |
| private database$: Observable<IDBDatabase> = defer(() => | |
| new Observable<IDBDatabase>((observer) => { | |
| const req = indexedDB.open(this.dbName, IDB_STORE_VERSION); | |
| req.onsuccess = (event) => { | |
| observer.next((event.target as IDBOpenDBRequest).result); | |
| observer.complete(); | |
| }; | |
| req.onerror = (event) => { | |
| observer.error(event); | |
| }; | |
| req.onupgradeneeded = (event) => { | |
| const db = (event.target as IDBOpenDBRequest).result; | |
| if (!db.objectStoreNames.contains(this.storeName)) { | |
| db.createObjectStore(this.storeName, { keyPath: 'id' }); | |
| } | |
| }; | |
| }).pipe( | |
| shareReplay({ bufferSize: 1, refCount: false }), // ref count? | |
| ), | |
| ); | |
| private cacheKey$: Observable<string> = defer(() => | |
| combineLatest({ | |
| userId: this.store.user.id().pipe(filter((id): id is string => !!id)), | |
| workspaceId: this.store.team | |
| .id() | |
| .pipe(filter((id): id is string => !!id)), | |
| }).pipe( | |
| map(({ userId, workspaceId }) => `${workspaceId}-${userId}`), | |
| distinctUntilChangedDeep(), | |
| shareReplay({ bufferSize: 1, refCount: false }), | |
| ), | |
| ); | |
| /** | |
| * Stops and cleans up all ongoing observables, timeouts, etc. | |
| **/ | |
| cancel() { | |
| if (this.idleId) { | |
| cancelIdleCallback(this.idleId); | |
| this.idleId = null; | |
| } | |
| this.writeOnIdleDebounced.cancel(); | |
| } | |
| /** | |
| * Reads the cached value from the IDB cache. If the data is not found, too | |
| * old, or was made for a different user or workspace, it returns null. | |
| */ | |
| read(objectKey: string): Observable<T | null> { | |
| return combineLatest({ | |
| key: this.cacheKey$, | |
| record: this.readRecord(objectKey), | |
| }).pipe( | |
| map(({ key, record }) => { | |
| return record?.key === key ? record.data : null; | |
| }), | |
| ); | |
| } | |
| private readRecord(objectKey: string): Observable<LayoutCacheData<T> | null> { | |
| return this.database$.pipe( | |
| catchError((error: unknown) => { | |
| trackHandledError('Failed to open IDB database', { | |
| origin: 'IdbStoreCache', | |
| feature: 'react-app', | |
| databaseName: this.dbName, | |
| cause: error, | |
| }); | |
| return of(null); | |
| }), | |
| switchMap((db): Observable<LayoutCacheData<T> | null> => { | |
| if (!db) { | |
| return of(null); | |
| } | |
| return this.idbRequestToObservable<LayoutCacheData<T> | null>( | |
| db | |
| .transaction(this.storeName, 'readonly') | |
| .objectStore(this.storeName) | |
| .get(objectKey), | |
| ); | |
| }), | |
| ); | |
| } | |
| /** | |
| * Writes the given data to the IDB cache on idle. | |
| **/ | |
| writeOnIdle = (objectKey: string, data: T) => { | |
| if (document.visibilityState === 'visible') { | |
| this.writeOnIdleDebounced(objectKey, data, Date.now()); | |
| } | |
| }; | |
| private writeOnIdleDebounced = debounce( | |
| (objectKey: string, data: T, timestamp: number) => { | |
| this.cancel(); | |
| this.idleId = requestIdleCallback(() => { | |
| combineLatest({ | |
| key: this.cacheKey$, | |
| db: this.database$, | |
| currentRecord: this.readRecord(objectKey), | |
| }) | |
| .pipe( | |
| take(1), | |
| filter(({ key, currentRecord }) => { | |
| if (!currentRecord || currentRecord.key !== key) { | |
| return true; // No cache found or cache key is wrong. | |
| } | |
| if (isEqual(currentRecord.data, data)) { | |
| globalThis.console.log('skip updating idb-cache: EQUAL'); | |
| return true; // No need to write the same data twice. | |
| } | |
| if (timestamp <= currentRecord.time) { | |
| globalThis.console.log('skip updating idb-cache: OLDER', { | |
| timestamp, | |
| currentRecordTime: currentRecord.time, | |
| }); | |
| return true; // Something already updated the cache with more recent data. | |
| } | |
| // Something already updated the cache with more recent data. | |
| return timestamp > currentRecord.time; | |
| }), | |
| switchMap(({ key, db }) => | |
| this.idbRequestToObservable( | |
| db | |
| .transaction(this.storeName, 'readwrite') | |
| .objectStore(this.storeName) | |
| .put({ | |
| time: timestamp, | |
| key, | |
| data, | |
| id: objectKey, | |
| } satisfies LayoutCacheData<T>), | |
| ).pipe( | |
| tap(() => { | |
| globalThis.console.log( | |
| 'saved cache to idb', | |
| data, | |
| Date.now(), | |
| ); | |
| }), | |
| ), | |
| ), | |
| ) | |
| .subscribe(); | |
| }); | |
| }, | |
| 1_000, | |
| ); | |
| private idbRequestToObservable<T>(request: IDBRequest<T>): Observable<T> { | |
| return new Observable<T>((observer) => { | |
| request.onsuccess = (event) => { | |
| observer.next((event.target as IDBRequest<T>).result); | |
| observer.complete(); | |
| }; | |
| request.onerror = (event) => { | |
| observer.error(event); | |
| }; | |
| }); | |
| } | |
| } | |
| interface LayoutCacheData<T> { | |
| time: number; | |
| key: string; | |
| data: T; | |
| id: string; | |
| } | |
| const IDB_STORE_VERSION = 1; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment