Skip to content

Instantly share code, notes, and snippets.

@faiwer
Created September 24, 2025 13:27
Show Gist options
  • Select an option

  • Save faiwer/4c38c763557a676e770498456f2c2bc6 to your computer and use it in GitHub Desktop.

Select an option

Save faiwer/4c38c763557a676e770498456f2c2bc6 to your computer and use it in GitHub Desktop.
idb.ts
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