agmission/Development/client/src/app/domain/services/browser-cache.service.ts
Devin Major 9303274349
Some checks are pending
Server Tests / Mocha – Unit & Utility Tests (push) Waiting to run
all Data Export API changes from April 28 2026
2026-04-28 15:44:46 -04:00

143 lines
4.7 KiB
TypeScript

import { Injectable } from '@angular/core';
import { from, Observable, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from './app-config.service';
import { AuthService } from './auth.service';
/** Shape of every entry stored in Cache Storage. */
export interface BrowserCacheEntry<T> {
data: T;
cachedAt: number; // epoch ms
}
/**
* Generic browser-side Cache Storage wrapper.
*
* Each logical cache is identified by a **cacheName** (e.g. `'agm-jobs-list-v1'`).
* Within that cache, individual entries are keyed by an arbitrary **key** string
* (typically a serialised set of query params).
*
* Usage:
* ```ts
* // Read
* this.browserCache.get<MyModel[]>('my-cache-v1', key, 60_000).subscribe(data => { ... });
*
* // Write
* this.browserCache.put('my-cache-v1', key, data);
*
* // Invalidate
* this.browserCache.invalidate('my-cache-v1');
* ```
*
* All operations are no-ops when the Cache Storage API is unavailable
* (e.g. in unit tests or older browsers).
*/
@Injectable({ providedIn: 'root' })
export class BrowserCacheService {
private readonly supported = typeof caches !== 'undefined';
private readonly fallbackMaxAgeMs = 60_000;
constructor(
private readonly appConfig: AppConfigService,
private readonly authSvc: AuthService
) {}
private ttlStorageKey(cacheName: string): string {
const userId = this.authSvc.user?._id || 'anonymous';
return `browser-cache-ttl:${userId}:${cacheName}`;
}
private get defaultMaxAgeMs(): number {
return this.appConfig.settings?.browserListCacheTtlMs || this.fallbackMaxAgeMs;
}
getTtl(cacheName: string): number {
const storedValue = localStorage.getItem(this.ttlStorageKey(cacheName));
if (storedValue === null) {
return this.defaultMaxAgeMs;
}
const parsedValue = Number(storedValue);
return Number.isFinite(parsedValue) && parsedValue >= 0
? parsedValue
: this.defaultMaxAgeMs;
}
setTtl(cacheName: string, ttlMs: number): number {
const normalizedValue = Number.isFinite(ttlMs) && ttlMs >= 0
? Math.floor(ttlMs)
: this.defaultMaxAgeMs;
localStorage.setItem(this.ttlStorageKey(cacheName), String(normalizedValue));
return normalizedValue;
}
/**
* Build the pseudo-URL used as the cache key inside a named Cache bucket.
* We prefix with a fixed path so it looks like a valid Request URL.
*/
private entryUrl(cacheName: string, key: string): string {
return `/browser-cache/${encodeURIComponent(cacheName)}?${key}`;
}
/**
* Retrieve a cached value.
*
* @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`).
* @param key Entry key — typically serialised query params.
* @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale.
* Defaults to `appConfig.browserListCacheTtlMs` or 60 000.
* @returns The cached value, or `null` when unavailable / stale / missing.
*/
get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null> {
if (!this.supported) return of(null);
const effectiveMaxAgeMs = maxAgeMs ?? this.getTtl(cacheName);
return from(caches.open(cacheName)).pipe(
switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))),
switchMap(response => {
if (!response) return of(null);
return from(response.json() as Promise<BrowserCacheEntry<T>>);
}),
switchMap((entry: BrowserCacheEntry<T> | null) => {
if (!entry) return of(null);
if (Date.now() - entry.cachedAt > effectiveMaxAgeMs) return of(null);
return of(entry.data);
}),
catchError(() => of(null))
);
}
/**
* Store a value in Cache Storage.
* Fire-and-forget — errors are silently swallowed so they never block the caller.
*
* @param cacheName Name of the Cache Storage bucket.
* @param key Entry key.
* @param data Value to store (must be JSON-serialisable).
*/
put<T>(cacheName: string, key: string, data: T): void {
if (!this.supported) return;
const entry: BrowserCacheEntry<T> = { data, cachedAt: Date.now() };
caches.open(cacheName)
.then(cache => cache.put(
this.entryUrl(cacheName, key),
new Response(JSON.stringify(entry), { headers: { 'Content-Type': 'application/json' } })
))
.catch(() => { /* silent */ });
}
/**
* Delete an entire Cache Storage bucket, invalidating all its entries.
* Typically called after a mutation (create / update / delete).
*
* @param cacheName Name of the Cache Storage bucket to delete.
*/
invalidate(cacheName: string): void {
if (!this.supported) return;
caches.delete(cacheName).catch(() => { /* silent */ });
}
}