Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import {
2 Cache,
3 MemoryStore,
4 wrapProducer,
5 type AnyParams,
6 type AnyValidators,
7 type ConsumerDirectives,
8 type Entry,
9 type ProducerDirectives,
10} from '../lib/cache/index.js';
11import { type ReadonlyDeep } from 'type-fest';
12
13import { jsonParse, jsonStringify } from './encoding.js';
14import { type JSON } from './json-schema-types.js';
15
16type Opts<KeyType, CachedContentType, CacheKeyType extends string = string> = {
17 /**
18 * This is the function responsible for returning the content that will be
19 * cached for the provided key.
20 */
21 producer(cacheKey: KeyType): Promise<CachedContentType>;
22 /**
23 * These are the "producer directives" (or a function that returns the
24 * producer directives given the producer's return value). These instruct the
25 * cache on how long the value is likely to still be correct (or at least
26 * worth serving from cache). Consumers can specify their own freshness
27 * requirements when reading from the cache, which may force the cache to get
28 * a new value even if the producer said the cached value is still probably
29 * ok (or, alternately, may allow a cached value to be used longer than the
30 * producer thinks is generally safe).
31 */
32 directives:
33 | ProducerDirectives
34 | ((it: ReadonlyDeep<CachedContentType>) => ProducerDirectives);
35 collapseOverlappingRequestsTime?: number;
36 numItemsLimit?: number;
37 onItemEviction?(value: ReadonlyDeep<CachedContentType>, key: KeyType): void;
38 keyGeneration?: {
39 toString(it: KeyType): CacheKeyType;
40 fromString(str: CacheKeyType): KeyType;
41 };
42};
43
44type CachedFn<KeyType, CachedContentType> = {
45 (
46 key: KeyType,
47 directives?: ConsumerDirectives,
48 ): Promise<ReadonlyDeep<CachedContentType>>;
49 close(): Promise<void>;
50 /** Invalidates the cached value for the given key. Used when the source data has been replaced (e.g. key rotation). */
51 invalidate?(key: KeyType): Promise<void>;
52};
53
54/**
55 * Basically, this is like {@link wrapProducer}, except it takes care of a bunch
56 * of other small details that our generic caching library doesn't and shouldn't
57 * know about, like that we're using an in-memory store; that we want to cache
58 * all produced values using the same directives (as given in the `directive`
59 * option); that we want to mark the cache result as readonly (because the
60 * in-memory store means it'll be reused between callers); etc.
61 */
62export function cached<
63 KeyType extends ReadonlyDeep<JSON>,
64 CachedContentType,
65 CacheKeyType extends string = string,
66>(
67 opts: Opts<KeyType, CachedContentType, CacheKeyType>,
68): CachedFn<KeyType, CachedContentType>;
69export function cached<
70 KeyType,
71 CachedContentType,
72 CacheKeyType extends string = string,
73>(
74 // NB: if the KeyType is not JSON-compatible, the caller must provide their
75 // own key generation and parsing functions, as the default ones won't work!
76 opts: Opts<KeyType, CachedContentType, CacheKeyType> &
77 Required<
78 Pick<Opts<KeyType, CachedContentType, CacheKeyType>, 'keyGeneration'>
79 >,
80): CachedFn<KeyType, CachedContentType>;
81export function cached<
82 KeyType,
83 CachedContentType,
84 CacheKeyType extends string = string,
85>(opts: Opts<KeyType, CachedContentType, CacheKeyType>) {
86 const {
87 directives,
88 producer,
89 numItemsLimit,
90 collapseOverlappingRequestsTime,
91 onItemEviction: givenOnItemEviction,
92 keyGeneration = {
93 // NB: these casts are not safe as far as TS is concerned because
94 // CacheKeyType can be instantiated with an arbitrary subtype of string,
95 // and jsonStringify obv can't produce all of those. However,
96 // jsonStringify and jsonParse are only used if the caller doesn't provide
97 // their own keyGeneration functions, and, in that case, `CacheKeyType` is
98 // totally unobservable outside this function, so it doesn't actually
99 // matter if it's instantiated with a string subtype that isn't
100 // JsonOf<KeyType>.
101 toString: jsonStringify as unknown as (it: KeyType) => CacheKeyType,
102 fromString: jsonParse as unknown as (it: CacheKeyType) => KeyType,
103 },
104 } = opts;
105 const finalOnItemEvication = givenOnItemEviction
106 ? (
107 it: Entry<ReadonlyDeep<CachedContentType>, AnyValidators, AnyParams>,
108 ) => {
109 givenOnItemEviction(
110 it.content,
111 keyGeneration.fromString(it.id as CacheKeyType),
112 );
113 }
114 : undefined;
115
116 const getWithCache = wrapProducer(
117 new Cache(
118 new MemoryStore({ numItemsLimit, onItemEviction: finalOnItemEvication }),
119 {
120 onGetAfterClose: 'return-nothing',
121 onStoreAfterClose: 'return-nothing',
122 },
123 ),
124 { collapseOverlappingRequestsTime },
125 async (req) => {
126 const origKey = keyGeneration.fromString(
127 req.id satisfies string as CacheKeyType,
128 );
129
130 // Always cast the produced result to be marked readonly, because the same
131 // cached object is gonna be returned for multiple requests, so making any
132 // mutations to that object would be an insanely bad idea that'd be
133 // impossible to reason about.
134 const content = (await producer(
135 origKey,
136 )) satisfies CachedContentType as ReadonlyDeep<CachedContentType>;
137
138 return {
139 content,
140 directives:
141 typeof directives === 'function' ? directives(content) : directives,
142 };
143 },
144 );
145
146 async function exposedGet(key: KeyType, directives?: ConsumerDirectives) {
147 const cacheKey = keyGeneration.toString(key);
148 return (await getWithCache({ id: cacheKey, directives })).content;
149 }
150
151 exposedGet.close = async () => getWithCache.cache.close();
152 exposedGet.invalidate = async (key: KeyType) => {
153 const cacheKey = keyGeneration.toString(key);
154 await getWithCache.cache.delete(cacheKey);
155 };
156 return exposedGet;
157}
158
159/**
160 * The type of functions returned by {@link cached}, which matches the type of
161 * the original producer, except that there's an added close() property/method.
162 */
163export type Cached<OriginalProducer extends (key: never) => Promise<unknown>> =
164 OriginalProducer extends (
165 key: infer KeyType extends ReadonlyDeep<JSON>,
166 ) => Promise<infer CacheContentType>
167 ? CachedFn<KeyType, CacheContentType>
168 : never;