AgentSkillsCN

Vendix Frontend Cache

Vendix 前端缓存

SKILL.md

vendix-frontend-cache

Frontend caching pattern for stats/dashboard endpoints using RxJS shareReplay with TTL.

Trigger

When implementing or modifying stats/dashboard endpoints in Angular services to reduce HTTP requests during navigation.

Context

Stats and dashboard endpoints are frequently called when users navigate between modules. Without caching, every navigation triggers new HTTP requests even when data hasn't changed.

Key insight: In Angular with lazy loading and standalone components, service instances are recreated on navigation. Instance variables don't persist - use static global cache.

Pattern

Static Global Cache (for simple endpoints)

typescript
import { tap, shareReplay } from 'rxjs/operators';

// Static cache outside class (persists across instances)
interface CacheEntry<T> {
  observable: T;
  lastFetch: number;
}

let statsCache: CacheEntry<Observable<ApiResponse<Stats>>> | null = null;

@Injectable({ providedIn: 'root' })
export class MyService {
  private readonly CACHE_TTL = 30000; // 30 seconds

  getStats(): Observable<ApiResponse<Stats>> {
    const now = Date.now();

    // Return cached if valid
    if (statsCache && (now - statsCache.lastFetch) < this.CACHE_TTL) {
      return statsCache.observable;
    }

    // Create new observable with cache
    const observable$ = this.http.get<ApiResponse<Stats>>(`${this.apiUrl}/stats`)
      .pipe(
        shareReplay({ bufferSize: 1, refCount: false }),
        tap(() => {
          if (statsCache) {
            statsCache.lastFetch = Date.now();
          }
        }),
      );

    statsCache = {
      observable: observable$,
      lastFetch: now,
    };

    return observable$;
  }

  invalidateCache(): void {
    statsCache = null;
  }
}

Map-Based Cache (for endpoints with entity IDs)

typescript
// Map-based cache for multiple entities
const storeStatsCache = new Map<number, CacheEntry<Observable<StoreStats>>>();

getStats(storeId: number): Observable<StoreStats> {
  const now = Date.now();
  const cached = storeStatsCache.get(storeId);

  if (cached && (now - cached.lastFetch) < this.CACHE_TTL) {
    return cached.observable;
  }

  const observable$ = this.http.get<StoreStats>(`${this.apiUrl}/store/${storeId}/stats`)
    .pipe(
      shareReplay({ bufferSize: 1, refCount: false }),
      tap(() => {
        const entry = storeStatsCache.get(storeId);
        if (entry) {
          entry.lastFetch = Date.now();
        }
      }),
    );

  storeStatsCache.set(storeId, {
    observable: observable$,
    lastFetch: now,
  });

  return observable$;
}

invalidateCache(storeId?: number): void {
  if (storeId) {
    storeStatsCache.delete(storeId);
  } else {
    storeStatsCache.clear();
  }
}

Conditional Caching (with parameters)

typescript
getStats(fromDate?: string, toDate?: string): Observable<Stats> {
  // Bypass cache with date parameters
  if (fromDate || toDate) {
    const params: any = {};
    if (fromDate) params.from = fromDate;
    if (toDate) params.to = toDate;

    return this.http.get<Stats>(`${this.apiUrl}/stats`, { params });
  }

  // Use cache without parameters
  const now = Date.now();
  if (statsCache && (now - statsCache.lastFetch) < this.CACHE_TTL) {
    return statsCache.observable;
  }

  const observable$ = this.http.get<Stats>(`${this.apiUrl}/stats`)
    .pipe(
      shareReplay({ bufferSize: 1, refCount: false }),
      tap(() => {
        if (statsCache) {
          statsCache.lastFetch = Date.now();
        }
      }),
    );

  statsCache = { observable: observable$, lastFetch: now };
  return observable$;
}

Key Points

When to use cache:

  • Dashboard general stats
  • List of entities without filters
  • Reference data (roles, currencies, etc.)

When NOT to use cache:

  • Entity-specific endpoints by ID (use Map instead)
  • With date/filters parameters (bypass cache)
  • Real-time data (logs, health metrics)
  • Paginated lists

Critical Settings:

  • refCount: false - Keeps observable alive even without subscribers
  • bufferSize: 1 - Replay last value to new subscribers
  • CACHE_TTL = 30000 - 30 second expiration
  • Static variables outside class - Persists across service instances

Implemented Services

Super-Admin (17 endpoints)

  • Dashboard Service (6): getDashboardStats, getOrganizationsStats, getUsersStats, getStoresStats, getDomainsStats, getRolesStats
  • Domains Service: getDomainStatsList
  • Organizations Service: getOrganizationStatsList
  • Stores Service: getStoreStatsList
  • Users Service: getUsersStats (conditional)
  • Roles Service: getRolesStats
  • Audit Service: getAuditStats (conditional)
  • Currencies Service: getCurrencyStats
  • Shipping Service: getMethodStats, getZoneStats
  • Payment Methods Service: getPaymentMethodsStats
  • Templates Service: getTemplateStats
  • Legal Documents Service: getAcceptanceStats (Map-based)

Organization Module (4 endpoints)

  • Dashboard Service: getDashboardStats (Map-based by orgId-period)
  • Users Service: getUsersStats
  • Audit Service: getAuditStats
  • Stores Service: getOrganizationStoreStats

Store Module (8 endpoints)

  • Dashboard Service: getDashboardStats, getProductsStats (both Map-based)
  • Orders Service: getOrderStats
  • Products Service: getProductStats (Map-based)
  • Inventory Service: getInventoryStats
  • Customers Service: getStats (Map-based)
  • Expenses Service: getExpensesSummary (conditional)

Cache Invalidation

Call invalidateCache() after:

  • Creating a new entity
  • Editing an entity
  • Deleting an entity

Example:

typescript
createItem(data: CreateDto): Observable<Item> {
  return this.http.post<Item>(this.apiUrl, data).pipe(
    tap(() => this.invalidateCache())
  );
}