Skip to content

Commit

Permalink
feat: support numeric fallbackToCache values
Browse files Browse the repository at this point in the history
in order to prevent too old caches to surface

also refactor internal option passing to context
  • Loading branch information
Xiphe committed Jul 17, 2022
1 parent c8e37a0 commit 5ab749a
Show file tree
Hide file tree
Showing 7 changed files with 93 additions and 42 deletions.
31 changes: 26 additions & 5 deletions src/cachified.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { format } from 'pretty-format';
import {
cachified,
CachifiedOptions,
Context,
createBatch,
CreateReporter,
CacheMetadata,
Expand Down Expand Up @@ -266,6 +267,26 @@ describe('cachified', () => {
`);
});

it('does not fall back to outdated cache', async () => {
const cache = new Map<string, CacheEntry<string>>();
const reporter = createReporter();

cache.set('test', createCacheEntry('ONE', { ttl: 5 }));
currentTime = 15;
const value = cachified({
cache,
key: 'test',
forceFresh: true,
reporter,
fallbackToCache: 10,
getFreshValue: () => {
throw '🤡';
},
});

await expect(value).rejects.toMatchInlineSnapshot(`"🤡"`);
});

it('it throws when cache fallback is disabled and getting fresh value fails', async () => {
const cache = new Map<string, CacheEntry<string>>();

Expand Down Expand Up @@ -850,12 +871,12 @@ function delay(ms: number) {
}

function createReporter() {
const reporter = jest.fn();
const creator = (options: CachifiedOptions<any>, metadata: CacheMetadata) => {
reporter({ name: 'init', key: options.key, metadata });
return reporter;
const report = jest.fn();
const creator = ({ key, metadata }: Omit<Context<any>, 'report'>) => {
report({ name: 'init', key, metadata });
return report;
};
creator.mock = reporter.mock;
creator.mock = report.mock;
return creator;
}

Expand Down
17 changes: 5 additions & 12 deletions src/cachified.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
Cache,
CacheEntry,
CacheMetadata,
applyDefaultOptions,
createContext,
} from './common';
import { CACHE_EMPTY, getCachedValue } from './getCachedValue';
import { getFreshValue } from './getFreshValue';
Expand All @@ -17,15 +17,8 @@ const pendingValuesByCache = new WeakMap<Cache<any>, Map<string, any>>();
export async function cachified<Value>(
options: CachifiedOptions<Value>,
): Promise<Value> {
const allOptions = applyDefaultOptions(options);
const { key, cache, ttl, forceFresh, staleWhileRevalidate, reporter } =
allOptions;
const metadata: CacheMetadata = {
ttl: ttl === Infinity ? null : ttl,
swv: staleWhileRevalidate === Infinity ? null : staleWhileRevalidate,
createdTime: Date.now(),
};
const report = reporter(allOptions, metadata);
const context = createContext(options);
const { key, cache, forceFresh, report, metadata } = context;

// Register this cache
if (!pendingValuesByCache.has(cache)) {
Expand All @@ -37,7 +30,7 @@ export async function cachified<Value>(
> = pendingValuesByCache.get(cache)!;

const cachedValue =
(!forceFresh && (await getCachedValue(allOptions, report))) || CACHE_EMPTY;
(!forceFresh && (await getCachedValue(context, report))) || CACHE_EMPTY;
if (cachedValue !== CACHE_EMPTY) {
return cachedValue;
}
Expand All @@ -53,7 +46,7 @@ export async function cachified<Value>(
let resolveFromFuture: (value: Value) => void;
const freshValue = Promise.race([
// try to get a fresh value
getFreshValue(allOptions, metadata, report),
getFreshValue(context, metadata, report),
// or when a future call is faster, we'll take it's value
// this happens when getting value of first call takes longer then ttl + second response
new Promise<Value>((r) => {
Expand Down
54 changes: 44 additions & 10 deletions src/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CreateReporter } from './reporter';
import type { CreateReporter, Reporter } from './reporter';

export interface CacheMetadata {
createdTime: number;
Expand Down Expand Up @@ -39,24 +39,58 @@ export interface CachifiedOptions<Value> {
getFreshValue: GetFreshValue<Value>;
checkValue?: (value: unknown) => boolean | string;
forceFresh?: boolean;
fallbackToCache?: boolean;
fallbackToCache?: boolean | number;
reporter?: CreateReporter<Value>;
ttl?: number;
staleWhileRevalidate?: number;
staleRefreshTimeout?: number;
}

export function applyDefaultOptions<Value>(
options: CachifiedOptions<Value>,
): Required<CachifiedOptions<Value>> {
return {
reporter: () => () => {},
export interface Context<Value>
extends Omit<
Required<CachifiedOptions<Value>>,
'fallbackToCache' | 'reporter'
> {
report: Reporter<Value>;
fallbackToCache: number;
metadata: CacheMetadata;
}

export function createContext<Value>({
fallbackToCache,
reporter,
...options
}: CachifiedOptions<Value>): Context<Value> {
const ttl = options.ttl ?? Infinity;
const staleWhileRevalidate = options.staleWhileRevalidate ?? 0;
const contextWithoutReport = {
checkValue: () => true,
ttl: Infinity,
staleWhileRevalidate: 0,
fallbackToCache: true,
ttl,
staleWhileRevalidate,
fallbackToCache:
fallbackToCache === false
? 0
: fallbackToCache === true || fallbackToCache === undefined
? Infinity
: fallbackToCache,
staleRefreshTimeout: 0,
forceFresh: false,
...options,
metadata: {
ttl: ttl === Infinity ? null : ttl,
swv: staleWhileRevalidate === Infinity ? null : staleWhileRevalidate,
createdTime: Date.now(),
},
};

const report =
reporter?.(contextWithoutReport) ||
(() => {
/* ¯\_(ツ)_/¯ */
});

return {
...contextWithoutReport,
report,
};
}
12 changes: 6 additions & 6 deletions src/getCachedValue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CachifiedOptions, CacheEntry } from './common';
import { Context, CacheEntry } from './common';
import { assertCacheEntry } from './assertCacheEntry';
import { HANDLE } from './common';
import { shouldRefresh } from './shouldRefresh';
Expand All @@ -7,7 +7,7 @@ import { Reporter } from './reporter';

export const CACHE_EMPTY = Symbol();
export async function getCacheEntry<Value>(
{ key, cache }: Required<CachifiedOptions<Value>>,
{ key, cache }: Context<Value>,
report: Reporter<Value>,
): Promise<CacheEntry<Value> | typeof CACHE_EMPTY> {
report({ name: 'getCachedValueStart' });
Expand All @@ -21,7 +21,7 @@ export async function getCacheEntry<Value>(
}

export async function getCachedValue<Value>(
options: Required<CachifiedOptions<Value>>,
context: Context<Value>,
report: Reporter<Value>,
): Promise<Value | typeof CACHE_EMPTY> {
const {
Expand All @@ -31,9 +31,9 @@ export async function getCachedValue<Value>(
staleRefreshTimeout,
checkValue,
getFreshValue: { [HANDLE]: handle },
} = options;
} = context;
try {
const cached = await getCacheEntry(options, report);
const cached = await getCacheEntry(context, report);

if (cached === CACHE_EMPTY) {
report({ name: 'getCachedValueEmpty' });
Expand All @@ -54,7 +54,7 @@ export async function getCachedValue<Value>(
setTimeout(() => {
report({ name: 'refreshValueStart' });
void cachified({
...options,
...context,
reporter: () => () => {},
forceFresh: true,
fallbackToCache: false,
Expand Down
15 changes: 9 additions & 6 deletions src/getFreshValue.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { CachifiedOptions, Cache, CacheMetadata } from './common';
import type { Context, CacheMetadata } from './common';
import { getCacheEntry, CACHE_EMPTY } from './getCachedValue';
import { shouldRefresh } from './shouldRefresh';
import { Reporter } from './reporter';

export async function getFreshValue<Value>(
options: Required<CachifiedOptions<Value>>,
context: Context<Value>,
metadata: CacheMetadata,
report: Reporter<Value>,
): Promise<Value> {
const { fallbackToCache, key, getFreshValue, forceFresh, cache, checkValue } =
options;
context;

let value: Value;
try {
Expand All @@ -21,9 +21,12 @@ export async function getFreshValue<Value>(

// in case a fresh value was forced (and errored) we might be able to
// still get one from cache
if (fallbackToCache && forceFresh) {
const entry = await getCacheEntry(options, report);
if (entry === CACHE_EMPTY) {
if (forceFresh && fallbackToCache > 0) {
const entry = await getCacheEntry(context, report);
if (
entry === CACHE_EMPTY ||
entry.metadata.createdTime + fallbackToCache < Date.now()
) {
throw error;
}
value = entry.value;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type {
Cache,
CacheEntry,
CacheMetadata,
Context,
} from './common';
export * from './reporter';
export { createBatch } from './createBatch';
Expand Down
5 changes: 2 additions & 3 deletions src/reporter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CacheMetadata, CachifiedOptions } from './common';
import { CacheMetadata, Context } from './common';

export type GetFreshValueStartEvent = {
name: 'getFreshValueStart';
Expand Down Expand Up @@ -98,6 +98,5 @@ export type CacheEvent<Value> =
export type Reporter<Value> = (event: CacheEvent<Value>) => void;

export type CreateReporter<Value> = (
options: Required<CachifiedOptions<Value>>,
metadata: CacheMetadata,
context: Omit<Context<Value>, 'report'>,
) => Reporter<Value>;

0 comments on commit 5ab749a

Please sign in to comment.