hoangsonww / hoangsonww/GhostCache-API-Cache

Stale-While-Revalidate (SWR) + Tag-Based Invalidation

Open
#2 0 comments 0 reactions 1 assignee Claimed by @hoangsonww View on GitHub
bug documentation duplicate enhancement good first issue help wanted question
Dominant language
TypeScript
Stars
11
Forks
7
PR merge metrics
No merged PRs in 30d

Description

**Summary**
Add first-class support for Stale-While-Revalidate (serve cached data instantly while refreshing in the background) and tag-based invalidation (purge groups of related entries at once). This will improve perceived performance and give developers fine-grained control over cache consistency across `fetch` and Axios.

## Motivation

* **Instant UX + Freshness**: Many apps prefer returning slightly stale data immediately, then updating silently. SWR is a proven pattern for this.
* **Simple cache busting**: Today you can clear the whole cache, or set/get individual keys. Real apps often need to invalidate *sets* of keys (e.g., “all `pokemon/*`” or “all user profile data”) when a mutation occurs.
* **Parity across adapters**: Support should work for in-memory, `localStorage`, `sessionStorage`, IndexedDB, and Redis adapters via a minimal, portable API.

## Goals

1. Serve stale entries instantly if within a configurable **stale window** while kicking off a background refresh.
2. Expose a **tag API** to associate one or more tags with a cache entry and invalidate by tag.
3. Keep existing API backward-compatible; new behavior is opt-in.
4. Work with both `fetch` interception and registered Axios instances.
5. Provide deterministic behavior in Node and browser environments.

## Non-Goals

* Reactive UI bindings (React hooks, etc.)—app code can subscribe to events we emit.
* Complex query invalidation logic (regex on keys is out of scope for v1; tag sets cover most needs).

## Proposed API

```ts
// New options on enableGhostCache
type GhostCacheOptions = {
ttl?: number; // existing
persistent?: boolean; // existing
maxEntries?: number; // existing
storage?: StorageOption; // existing

swr?: {
enabled?: boolean; // default false
staleTtl?: number; // ms window during which stale can be served (<= ttl)
revalidateOnFocus?: boolean; // browser only; triggers background refresh on window focus
revalidateOnReconnect?: boolean; // triggers on online event
};
};

// New manual cache APIs
export function setCache(
key: string,
value: any,
opts?: { ttl?: number; tags?: string[] } // <-- NEW tags
): Promise;

export function getCache(
key: string,
opts?: { swr?: boolean } // when true, returns stale if available & schedules refresh
): Promise;

export function invalidateTags(tags: string | string[]): Promise; // <-- NEW

// Optional: events for UI layers/devtools
export type GhostCacheEvent =
| { type: 'cache_hit'; key: string; stale: boolean }
| { type: 'cache_miss'; key: string }
| { type: 'revalidate_start'; key: string }
| { type: 'revalidate_success'; key: string }
| { type: 'revalidate_error'; key: string; error: unknown };

export function onGhostCacheEvent(listener: (e: GhostCacheEvent) => void): () => void;
```

### Request-Level Overrides

```ts
// fetch: opt-in via Request init
fetch(url, {
ghostCache: {
swr: true, // enable SWR for this request only
tags: ['pokemon', 'list']
}
});

// axios: opt-in via config
api.get('/pokemon/ditto', {
ghostCache: { swr: true, tags: ['pokemon', 'detail:ditto'] }
});
```

> If `swr.enabled` is false globally, per-request `ghostCache.swr === true` still enables SWR for that call.

## Behavior Details

* **Cache states**

* **Fresh** (`now < createdAt + ttl`): return cached response; no refresh.
* **Stale-eligible** (`now < createdAt + staleTtl` and `swr` enabled): return cached response immediately; trigger **background revalidation**; on success, cache new value and emit `revalidate_success`.
* **Expired**: perform network request, cache, return result.

* **Deduping**: if multiple identical requests arrive while a revalidation is in flight, they should **share** the same network promise (no thundering herd).

* **Tags**: when `setCache` or an intercepted request completes, store any provided `tags`. `invalidateTags(['pokemon'])` removes all entries associated with those tags across adapters.

## Storage Adapter Contract Changes

All existing adapters continue to work. To support tags efficiently, add two optional methods:

```ts
interface IStorageAdapter {
// existing get/set/delete/clear...

// Optional for performance; fallback uses full scan if not implemented.
addTags?(key: string, tags: string[]): Promise;
deleteByTags?(tags: string[]): Promise;
}
```

* **In-memory**: maintain a `Map>` for tag index.
* **localStorage/sessionStorage**: maintain a serialized tag index key (e.g., `__ghostcache_tag_index__`).
* **IndexedDB**: add an index on `tags`.
* **Redis**: store per-tag sets (`SADD tag: key`), and on invalidate use `SMEMBERS` then `DEL`/`UNLINK`.

Adapters that don’t override `deleteByTags` fall back to scanning keys (OK for small caches).

## Examples

```ts
enableGhostCache({
ttl: 60_000,
swr: { enabled: true, staleTtl: 30_000, revalidateOnFocus: true }
});

// Serve stale instantly, refresh in background
await fetch('https://pokeapi.co/api/v2/pokemon/ditto', {
ghostCache: { swr: true, tags: ['pokemon', 'detail:ditto'] }
});

// After a mutation:
await invalidateTags(['pokemon']); // bust all pokemon-related entries
```

## Acceptance Criteria

* [ ] Opt-in SWR works for both `fetch` and registered Axios instances.
* [ ] Background revalidation returns stale data immediately and updates cache on success.
* [ ] Request deduplication prevents duplicate network calls during revalidation.
* [ ] Tagging works via:

* [ ] per-request `ghostCache.tags`
* [ ] manual `setCache(..., { tags })`
* [ ] `invalidateTags([...])` purges matching entries across adapters.
* [ ] Backward compatibility: existing APIs unaffected when `swr.enabled` is not used.
* [ ] Comprehensive tests:

* [ ] In-memory adapter (SWR + tags)
* [ ] Browser-like storage (localStorage/sessionStorage) via JSDOM/mocks
* [ ] IndexedDB (using fake-indexeddb)
* [ ] Redis adapter (integration test behind env var; otherwise mocked)
* [ ] Deduping under concurrency
* [ ] Event emission on hits/misses/revalidate
* [ ] Updated README with examples and adapter notes.

## Potential Follow-Ups (separate issues)

* **DevTools overlay** (small panel that logs cache hits/misses in dev).
* **Cache hydration/serialization** for SSR frameworks.
* **ETag/Last-Modified awareness**: use conditional requests during revalidation.

## Additional Context

This feature aligns with modern caching patterns (e.g., SWR/RTK Query) and complements GhostCache’s multi-adapter design. It should significantly improve perceived performance while offering safe, explicit invalidation.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.