Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 308 lines 12 kB view raw
1import { EventEmitter } from "events"; 2import _ from "lodash"; 3 4import { 5 type Entry, 6 type NormalizeParamName, 7 type NormalizeParamValue, 8} from "./types/06_Normalization.js"; 9import { 10 type AnyParams, 11 type AnyParamValue, 12 type AnyValidators, 13 type ConsumerRequest, 14 type Logger, 15 type ProducerResultResource, 16 type Store, 17 type Vary, 18} from "./types/index.js"; 19import { type Bind1 } from "./types/utils.js"; 20import { 21 normalizeParams, 22 normalizeProducerResultResource, 23 normalizeVary, 24} from "./utils/normalization.js"; 25import * as entryUtils from "./utils/normalizedProducerResultResourceHelpers.js"; 26import { defaultLoggersByComponent } from "./utils/utils.js"; 27 28const { sortBy, groupBy } = _; 29 30type OnRequestAfterClose = "throw" | "return-nothing"; 31 32/** 33 * This class implements a cache using a generalized version of HTTP's 34 * underlying caching model, but w/o encoding HTTP-specific details (like header 35 * parsing), so that it can be useful in more contexts. As part of this 36 * generalization, this class talks about a cached value's "id and request 37 * params" rather than its "URI and request headers", and cache directives are 38 * provided as explicit arguments (not header strings). Similarly, it refers to 39 * the "producer and consumer" of cached values, rather than the "server and the 40 * client". Beyond renaming, it leaves open the set of available validators for 41 * users to define (e.g., db row version numbers), rather than hard-coding HTTP 42 * validators like etags and last-modified dates, and it supports a set of 43 * directives somewhat more general than their HTTP equivalents. 44 * 45 * For (critical) background details on the HTTP caching model, see the docs. 46 * 47 * TODO: support the concept of warnings. 48 * See https://tools.ietf.org/html/rfc7234#section-5.5 49 */ 50export default class Cache< 51 Content, 52 Validators extends AnyValidators = AnyValidators, 53 Params extends AnyParams = AnyParams, 54 Id extends string = string, 55> { 56 private readonly logger: Bind1<Logger, "cache">; 57 public readonly emitter = new EventEmitter(); 58 private closed = false; 59 private readonly onGetAfterClose: OnRequestAfterClose; 60 private readonly onStoreAfterClose: OnRequestAfterClose; 61 public readonly normalizeParamName: NormalizeParamName<Params>; 62 public readonly normalizeParamValue: NormalizeParamValue<Params>; 63 64 /** 65 * @param dataStore The backing store that will actually hold cache entries. 66 */ 67 constructor( 68 private readonly dataStore: Store<Content, Validators, Params>, 69 options: { 70 logger?: Logger; 71 onGetAfterClose?: OnRequestAfterClose; 72 onStoreAfterClose?: OnRequestAfterClose; 73 normalizeParamName?: NormalizeParamName<Params>; 74 normalizeParamValue?: NormalizeParamValue<Params>; 75 } = {}, 76 ) { 77 const unboundLogger = options.logger ?? defaultLoggersByComponent.cache; 78 this.logger = unboundLogger.bind(null, "cache"); 79 this.onGetAfterClose = options.onGetAfterClose ?? "throw"; 80 this.onStoreAfterClose = options.onStoreAfterClose ?? "throw"; 81 this.normalizeParamName = options.normalizeParamName ?? ((it) => it); 82 this.normalizeParamValue = 83 options.normalizeParamValue ?? 84 (<K extends keyof Params>(_name: K, v: AnyParamValue) => 85 v as Params[K] & AnyParamValue); 86 } 87 88 private static bestEntry< 89 Content, 90 Validators extends AnyValidators, 91 Params extends AnyParams, 92 >(suitableEntries: readonly Entry<Content, Validators, Params>[]) { 93 // "When more than one suitable response is stored, a cache MUST use 94 // the most recent response (as determined by the Date header field)." 95 // https://tools.ietf.org/html/rfc7234#section-4 96 return sortBy(suitableEntries, [(it) => entryUtils.birthDate(it)]).at(-1); 97 } 98 99 // Create this as an instance member to get `this` binding 100 private normalizeParams = (params: Partial<Params>) => 101 normalizeParams(this.normalizeParamName, this.normalizeParamValue, params); 102 103 // Create this as an instance member to get `this` binding 104 private normalizeVary = (vary: Vary<Params>) => 105 normalizeVary(this.normalizeParamName, this.normalizeParamValue, vary); 106 107 /** 108 * Gets relevant items from the cache, always returning a promise for an 109 * object with four possible keys: 110 * 111 * - `usable`: this is the cached value (if any) that satisfies the consumer's 112 * request, given its cache directives, without requiring even background 113 * revalidation. **If this key holds a value, all other keys in this object 114 * will be undefined/empty.** This value will almost always be fresh, since 115 * stale values aren't usable by defualt; the exception is if the consumer 116 * allowed stale responses (sans revalidation) through the `maxStale` 117 * directive. If multiple cached values would've have been suitable, this 118 * holds the preferred one (which currently means the newest). 119 * 120 * - `usableWhileRevalidate`: this holds the preferred response (if any) 121 * that's usable to satisfy the client's request, but that must be 122 * (re-)validated in the background. 123 * 124 * - `usableIfError`: holds an entry (if any) that's usable only in case of an 125 * error reaching the producer while trying to fetch/revalidate the cached 126 * value. If there's a `usableWhileRevalidate` response, this key will 127 * always be empty [because the usableWhileRevalidate response should be 128 * returned before calling the producer, so there's no chance on an error.] 129 * 130 * - `validatable`: when validation is necessary (either because no usable 131 * response is held by the cache, or the usable response requires 132 * background re-validation), this array holds all entries in the cache 133 * that have validation info -- including, possibly, responses present in 134 * the other returned keys -- and that would be usable were the producer 135 * to confirm (revalidate) that the resource's current state matches the 136 * state identified by the validation info. Otherwise, this array is empty. 137 * These are returned so that the user can make a conditional request for 138 * the latest content that takes into account the validation info (e.g., 139 * the etags w/ `If-None-Match`) of these saved responses. These responses 140 * are probably stale, but it's possible they're not (e.g., if consumer 141 * used a maxAge directive shorter than the producer's freshness lifetime). 142 */ 143 public async get(req: ConsumerRequest<Params, Id>): Promise<{ 144 usable?: Entry<Content, Validators, Params> | undefined; 145 usableWhileRevalidate?: Entry<Content, Validators, Params> | undefined; 146 usableIfError?: Entry<Content, Validators, Params> | undefined; 147 validatable: Entry<Content, Validators, Params>[]; 148 }> { 149 if (this.closed) { 150 if (this.onGetAfterClose === "throw") { 151 this.logger("trace", "received request when closed and throwing"); 152 throw new Error("Store has been closed..."); 153 } 154 this.logger( 155 "trace", 156 "received request when closed, so returning no entries", 157 ); 158 return { 159 validatable: [], 160 }; 161 } 162 163 const { id, params, directives } = req; 164 const now = new Date(); 165 const normalizedParams = this.normalizeParams(params); 166 167 this.logger("trace", "received request", { id, params, normalizedParams }); 168 this.logger("trace", "requested entries from the store"); 169 170 const cacheEntries = await this.dataStore.get(id, normalizedParams); 171 const classifiedEntries = groupBy(cacheEntries, (it) => 172 entryUtils.classify(it, directives, now), 173 ); 174 175 this.logger( 176 "trace", 177 "received entries from the store, and classified them", 178 classifiedEntries, 179 ); 180 181 const usableEntries = 182 classifiedEntries[entryUtils.EntryClassification.Usable]; 183 184 if (usableEntries) { 185 const res = { 186 // Non-null assertion is safe because of lodash groupBy mechanics. 187 usable: Cache.bestEntry(usableEntries)!, 188 validatable: [], 189 }; 190 191 this.logger("trace", "chose/returned this data", res); 192 return res; 193 } 194 195 const validatableEntries = cacheEntries.filter(entryUtils.isValidatable); 196 197 const usableWhileRevalidateEntries = 198 classifiedEntries[entryUtils.EntryClassification.UsableWhileRevalidate]; 199 200 if (usableWhileRevalidateEntries) { 201 const res = { 202 // Non-null assertion is safe because of lodash groupBy mechanics. 203 usableWhileRevalidate: Cache.bestEntry(usableWhileRevalidateEntries)!, 204 validatable: validatableEntries, 205 }; 206 207 this.logger("trace", "chose/returned this data", res); 208 return res; 209 } 210 211 const usableIfErrorEntries = 212 classifiedEntries[entryUtils.EntryClassification.UsableIfError]; 213 214 const res = { 215 usableIfError: usableIfErrorEntries 216 ? // Non-null assertion is safe because of lodash groupBy mechanics. 217 Cache.bestEntry(usableIfErrorEntries)! 218 : undefined, 219 validatable: validatableEntries, 220 }; 221 222 this.logger("trace", "chose/returned this data", res); 223 return res; 224 } 225 226 /** 227 * Stores ProducerResultResources that it assumes were _just now_ retreived 228 * from the producer. If the result wasn't retreived just now, its retreival 229 * time can be specified. 230 */ 231 public async store( 232 data: readonly ProducerResultResource<Content, Validators, Params>[], 233 ) { 234 if (this.closed) { 235 if (this.onStoreAfterClose === "throw") { 236 this.logger("trace", "received store request when closed and throwing"); 237 throw new Error("Store has been closed..."); 238 } 239 this.logger( 240 "trace", 241 "received store request after throwing and doing nothing", 242 ); 243 return; 244 } 245 246 const now = new Date(); 247 const entriesWithTimes = data.map((it) => { 248 const entry = normalizeProducerResultResource( 249 this.normalizeVary, 250 it, 251 now, 252 ); 253 return { entry, maxStoreForSeconds: calculateStoreFor(entry, now) }; 254 }); 255 256 this.logger( 257 "trace", 258 "storing the following entries with (possibly inferred) storeFor times", 259 entriesWithTimes, 260 ); 261 262 entriesWithTimes.forEach(({ entry, maxStoreForSeconds }) => { 263 this.emitter.emit("store", entry, maxStoreForSeconds); 264 }); 265 266 return this.dataStore.store(entriesWithTimes); 267 } 268 269 /** 270 * Deletes all stored entries for the given resource id. Used for cache 271 * invalidation (e.g. when a signing key is rotated). 272 */ 273 public async delete(id: Id): Promise<void> { 274 return this.dataStore.delete(id); 275 } 276 277 public async close(timeout?: number) { 278 this.closed = true; 279 return this.dataStore.close(timeout); 280 } 281} 282 283/** 284 * Calculates the maximum amount of time -- in seconds! -- that the backing 285 * store may store the entry. It considers the producer's requested storeFor 286 * time, and when the data will become definitively useless. 287 * 288 * @param entry The entry who's time-to-store should be calculated 289 * @param at The date when the entry will be stored. This effects how long it 290 * should be stored for because, as entries get closer to the end of their 291 * freshness lifetime, the suggested storeFor time may go down (when it isn't 292 * dictated by the producer's directives). 293 */ 294function calculateStoreFor( 295 entry: Entry<unknown, AnyValidators, AnyParams>, 296 at: Date, 297) { 298 const producerStoreFor = entry.directives.storeFor; 299 const requestedStoreFor = 300 producerStoreFor !== undefined 301 ? producerStoreFor - entry.initialAge 302 : Infinity; 303 304 return Math.max( 305 0, 306 Math.min(requestedStoreFor, entryUtils.potentiallyUsefulFor(entry, at)), 307 ); 308}