Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 132 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,72 @@ const BookList = async ({ dispatch, currentUser }) => {
export default BookList;
```

##### Tag-Based Cache Invalidation

When using persistent cache with tags, you can selectively invalidate cache entries without having to clear the entire cache. Use the `invalidateFetchyeTags` API to invalidate cached entries by their tags.

Tags are particularly useful when you want to implement custom scoping strategies. For example, you could tag cache entries with both user-specific (`'book:${userId}'`) and collection-level (`'books'`) tags to enable invalidation at different scopes:

- **User-specific invalidation**: If a user makes a change that affects their cached data, invalidate tags like `['book:user123']` to clear only that user's cache
- **Collection-wide invalidation**: If the book catalog is updated, invalidate the `['books']` tag to clear all book caches across all users

```jsx
// BookList.server.jsx
import { oneFetchye } from 'fetchye-one-app';
import { invalidateFetchyeTags } from 'fetchye';

const BookList = async ({ dispatch, currentUser }) => {
const { data } = await dispatch(oneFetchye('http://example.com/api/books/', {
persistentCache: {
isolationKey: currentUser.id,
ttl: 30000,
// associating multiple tags enables flexible invalidation strategies
// use your own scoping system - e.g. domain:identifier or collection:scope
tags: [`books:${currentUser.id}`, 'books'],
},
}));

return (
<ul>
{data.body.map((book) => (
<li key={book.id}>{book.title} by {book.author}</li>
))}
</ul>
);
};

export default BookList;

// In an API route or server action, when the book catalog updates:
export async function updateBookCatalog(bookData) {
// Update your upstream data source
await saveToBackend(bookData);

// Invalidate the 'books' tag to clear all cached book lists across all users
await invalidateFetchyeTags(['books']);
}

// When a specific user makes a change:
export async function updateUserBookPreferences(currentUser, preferences) {
// Update your upstream data source
await saveToBackend(preferences);

// Invalidate only this user's book cache
await invalidateFetchyeTags([`books:${currentUser.id}`]);
}

// You can also invalidate multiple tags at once:
export async function clearAllCaches() {
await invalidateFetchyeTags(['books', 'profile', 'comments']);
}
```

**Tag scoping guidelines:**
- Use a consistent delimiter (e.g., `:`) to structure your tags hierarchically
- Combine broad tags (e.g., `'books'`) with specific tags (e.g., `'books:user123'`) to enable flexible invalidation
- Keep tag names descriptive and stable across your application
- Consider your cache invalidation needs when designing your tag structure

#### Next.JS SSR

```jsx
Expand Down Expand Up @@ -973,13 +1039,14 @@ const ParentComponent = ({ children }) => (
- [Next.JS SSR](#nextjs-ssr)
- [Write your own Cache](#write-your-own-cache)
- [🎛️ API](#️-api)
- [`useFetchye`](#usefetchye)
- [`makeServerFetchye`](#makeserverfetchye)
- [`makeOneServerFetchye`](#makeoneserverfetchye)
- [oneFetchye](#onefetchye)
- [streamFetchye](#streamfetchye)
- [useStreamedFetchye](#usestreamedfetchye)
- [Providers](#providers)
- [`useFetchye`](#usefetchye)
- [`makeServerFetchye`](#makeserverfetchye)
- [`makeOneServerFetchye`](#makeoneserverfetchye)
- [oneFetchye](#onefetchye)
- [streamFetchye](#streamfetchye)
- [useStreamedFetchye](#usestreamedfetchye)
- [invalidateFetchyeTags](#invalidatefetchyetags)
- [Providers](#providers)
- [`FetchyeProvider`](#fetchyeprovider)
- [`FetchyeReduxProvider`](#fetchyereduxprovider)
- [`OneFetchyeProvider`](#onefetchyeprovider)
Expand Down Expand Up @@ -1075,10 +1142,11 @@ const { data, error } = await fetchye(key, options, fetcher);

**`persistentCache` Option (RSC variant only)**

| name | type | required | description |
|------------------|-----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| `isolationKey` | `String` | `true` | Required when using persistent cache. Uniquely identifies the audience for the cached data (e.g. user id, tenant id), preventing cross-user leaks. |
| `ttl` | `Number` | `false` | Time in milliseconds to cache the resolved response. Cannot be `Infinity`. Defaults to 5 minutes (300000 ms) if not specified. |
| name | type | required | description |
|------------------|-----------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| `isolationKey` | `String` | `true` | Required when using persistent cache. Uniquely identifies the audience for the cached data (e.g. user id, tenant id), preventing cross-user leaks. |
| `ttl` | `Number` | `false` | Time in milliseconds to cache the resolved response. Cannot be `Infinity`. Defaults to 5 minutes (300000 ms) if not specified. |
| `tags` | `String[]` | `false` | An array of tags to associate with the cached entry. Tags enable invalidation of related cache entries via `invalidateFetchyeTags`. Must be a non-empty array when provided. |

**`fetchye` Returns**

Expand Down Expand Up @@ -1261,6 +1329,59 @@ const Container = () => (

A promise resolving to the streamed or local promise.

### invalidateFetchyeTags

Invalidates cache entries by their tags in the persistent cache. This function is only available when using React Server Components with the persistent cache feature.

**Description**

This function leverages the underlying [Cacheable](https://npmjs.com/package/cacheable) library's tag-based invalidation to clear cached entries that match the specified tags.

**Shape**

```js
import { invalidateFetchyeTags } from 'fetchye';

// Invalidate one or more tags
const invalidateAllAction = async () => {
await invalidateFetchyeTags(['books', 'profile', 'comments']);
};
```

**Arguments**

| name | type | required | description |
|--------|------------------------|----------|----------------------------------------------------------------------------------------|
| `tags` | `String[]` | `true` | An array of tags to invalidate. Must contain at least one tag. |

**Returns**

A `Promise<void>` that resolves when the tags have been invalidated.

**Errors**

Throws an error if:
- An empty array is passed as the `tags` parameter
- The underlying cache operation fails

**Example**

```js
import { invalidateFetchyeTags } from 'fetchye';

// Invalidate a user's cached books when they make a change
export async function moveUserBook(userId, bookId, newCollection) {
await updateBackend(userId, bookId, newCollection);
await invalidateFetchyeTags([`books:${userId}`]);
}

// Invalidate multiple tags when the book catalog is updated
export async function updateBookCatalog(bookData) {
await updateBackend(bookData);
await invalidateFetchyeTags(['books', 'bestsellers', 'new-releases']);
}
```

### Providers

A Provider creates a React Context to connect all the `useFetchye` Hooks into a centrally stored cache.
Expand Down
1 change: 1 addition & 0 deletions packages/fetchye/__tests__/react-server/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('react-server/index', () => {
Array [
"computeKey",
"ignoreHeadersByKey",
"invalidateFetchyeTags",
"makeServerFetchye",
]
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,5 +451,45 @@ describe('react-server/makeServerFetchye', () => {
expect(raw.expires).toBeGreaterThanOrEqual(beforeSet + 60000 - 1000);
expect(raw.expires).toBeLessThanOrEqual(beforeSet + 60000 + 1000);
});

it('supports storing cache entries with tags for later invalidation', async () => {
const fetchye = makeServerFetchye({
fetchClient,
promiseStore,
});

await fetchye('http://example.com/persistent-tags', {
persistentCache: {
isolationKey: 'tenant-a',
tags: ['users', 'profile'],
},
});

const persistentCacheKey = getPersistentCacheKey(
computeKey('http://example.com/persistent-tags'),
'tenant-a'
);

// The cache entry exists
await expect(persistentCacheStore.has(persistentCacheKey)).resolves.toBe(true);
});

it('throws when persistentCache.tags is invalid', async () => {
await expect(makeServerFetchye({
fetchClient,
promiseStore,
})('http://example.com/persistent', {
persistentCache: { isolationKey: 'tenant-a', tags: [] },
})).rejects.toThrow('makeServerFetchye persistentCache.tags must be a non-empty array when provided');
});

it('throws when persistentCache.tags is not an array', async () => {
await expect(makeServerFetchye({
fetchClient,
promiseStore,
})('http://example.com/persistent', {
persistentCache: { isolationKey: 'tenant-a', tags: 'users' },
})).rejects.toThrow('makeServerFetchye persistentCache.tags must be a non-empty array when provided');
});
});
});
65 changes: 65 additions & 0 deletions packages/fetchye/__tests__/react-server/persistentCache.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
validatePersistentCache,
getPersistentCacheKey,
DEFAULT_PERSISTENT_CACHE_TTL,
invalidateFetchyeTags,
} from '../../src/react-server/persistentCache';

describe('react-server/persistentCache', () => {
Expand Down Expand Up @@ -56,6 +57,30 @@ describe('react-server/persistentCache', () => {
ttl: 1000,
})).not.toThrow();
});

it('does not throw for a valid persistentCache config with tags', () => {
expect(() => validatePersistentCache({
isolationKey: 'tenant-a',
ttl: 1000,
tags: ['users', 'profile'],
})).not.toThrow();
});

it('throws when tags is an empty array', () => {
expect(() => validatePersistentCache({
isolationKey: 'tenant-a',
ttl: 1000,
tags: [],
})).toThrow('makeServerFetchye persistentCache.tags must be a non-empty array when provided');
});

it('throws when tags is not an array', () => {
expect(() => validatePersistentCache({
isolationKey: 'tenant-a',
ttl: 1000,
tags: 'users',
})).toThrow('makeServerFetchye persistentCache.tags must be a non-empty array when provided');
});
});

describe('getPersistentCacheKey', () => {
Expand All @@ -72,4 +97,44 @@ describe('react-server/persistentCache', () => {
it('exposes a sane default ttl', () => {
expect(DEFAULT_PERSISTENT_CACHE_TTL).toBe(5 * 60 * 1000);
});

describe('invalidateFetchyeTags', () => {
it('invalidates cache entries by a single tag', async () => {
await persistentCacheStore.set('key1', { data: 'value1' }, { ttl: 10000, tags: ['users'] });
await persistentCacheStore.set('key2', { data: 'value2' }, { ttl: 10000, tags: ['posts'] });

// Verify both entries exist before invalidation
expect(await persistentCacheStore.get('key1')).toEqual({ data: 'value1' });
expect(await persistentCacheStore.get('key2')).toEqual({ data: 'value2' });

await invalidateFetchyeTags(['users']);

// After invalidating the 'users' tag, the next get should return undefined
expect(await persistentCacheStore.get('key1')).toBeUndefined();
expect(await persistentCacheStore.get('key2')).toEqual({ data: 'value2' });
});

it('invalidates cache entries by multiple tags', async () => {
await persistentCacheStore.set('key1', { data: 'value1' }, { ttl: 10000, tags: ['users', 'profile'] });
await persistentCacheStore.set('key2', { data: 'value2' }, { ttl: 10000, tags: ['posts'] });
await persistentCacheStore.set('key3', { data: 'value3' }, { ttl: 10000, tags: ['comments'] });

// Verify all entries exist before invalidation
expect(await persistentCacheStore.get('key1')).toEqual({ data: 'value1' });
expect(await persistentCacheStore.get('key2')).toEqual({ data: 'value2' });
expect(await persistentCacheStore.get('key3')).toEqual({ data: 'value3' });

await invalidateFetchyeTags(['users', 'posts']);

expect(await persistentCacheStore.get('key1')).toBeUndefined();
expect(await persistentCacheStore.get('key2')).toBeUndefined();
expect(await persistentCacheStore.get('key3')).toEqual({ data: 'value3' });
});

it('throws when provided an empty array', async () => {
await expect(invalidateFetchyeTags([])).rejects.toThrow(
'invalidateFetchyeTags requires at least one tag'
);
});
});
});
2 changes: 2 additions & 0 deletions packages/fetchye/src/react-server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
import makeServerFetchye from './makeServerFetchye';
import { ignoreHeadersByKey } from '../mapOptionsToKeyHelpers';
import { computeKey } from '../computeKey';
import { invalidateFetchyeTags } from './persistentCache';

export {
makeServerFetchye,
ignoreHeadersByKey,
computeKey,
invalidateFetchyeTags,
};
7 changes: 5 additions & 2 deletions packages/fetchye/src/react-server/makeServerFetchye.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,12 @@ const makeServerFetchye = ({
const res = await runDeduped();

if (persistentCache) {
const { isolationKey, ttl } = persistentCache;
const { isolationKey, ttl, tags } = persistentCache;
const persistentCacheKey = getPersistentCacheKey(computedKey, isolationKey);
await persistentCacheStore.set(persistentCacheKey, res, ttl);
const storeOptions = {
ttl, tags,
};
await persistentCacheStore.set(persistentCacheKey, res, storeOptions);
}
return {
data: coerceSsrField(res.data),
Expand Down
26 changes: 26 additions & 0 deletions packages/fetchye/src/react-server/persistentCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ export const DEFAULT_PERSISTENT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
* (`CacheableMemory`, via `createKeyv()`), so entries always expire on their own.
*
* All reads/writes go through cacheable's native, promise-based `get`/`set` API.
*
* Tag-based invalidation is enabled to support invalidating groups of cache entries by tags.
*/
export const persistentCacheStore = new Cacheable({
ttl: DEFAULT_PERSISTENT_CACHE_TTL,
tags: true,
});

/**
Expand All @@ -50,6 +53,11 @@ export const validatePersistentCache = (persistentCache) => {
if (persistentCache.ttl === Number.POSITIVE_INFINITY) {
throw new Error('makeServerFetchye persistentCache.ttl cannot be Infinity');
}
if (persistentCache.tags !== undefined
&& (!Array.isArray(persistentCache.tags)
|| persistentCache.tags.length === 0)) {
throw new Error('makeServerFetchye persistentCache.tags must be a non-empty array when provided');
}
};

/**
Expand All @@ -61,3 +69,21 @@ export const getPersistentCacheKey = (computedKey, isolationKey) => computeHash(
[computedKey.hash, isolationKey],
{ respectType: false }
);

/**
* Invalidates all cache entries associated with the given tags.
* This leverages the underlying cacheable instance's tag-based invalidation.
*
* This function is intended to be called inside a server action, to allow the next
* RSC render to fetch fresh data.
*
* @param {string[]} tags - An array of tags to invalidate
* @returns {Promise<void>}
*/
export const invalidateFetchyeTags = async (tags) => {
if (tags.length === 0) {
throw new Error('invalidateFetchyeTags requires at least one tag');
}

await persistentCacheStore.tags.invalidateTags(tags);
};
Loading