├── .nvmrc ├── .gitignore ├── .prettierrc ├── .npmignore ├── .releaserc.yml ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── release.yml ├── jest.config.js ├── tsconfig.json ├── src ├── testHelpers.spec.ts ├── index.ts ├── assertCacheEntry.ts ├── softPurge.ts ├── checkValue.ts ├── isExpired.ts ├── testHelpers.ts ├── configure.ts ├── StandardSchemaV1.ts ├── getFreshValue.ts ├── softPurge.spec.ts ├── cachified.ts ├── createBatch.ts ├── getCachedValue.ts ├── reporter.ts ├── reporter.spec.ts ├── common.ts └── cachified.spec.ts ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | dist 4 | examples 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "jsxBracketSameLine": false, 4 | "singleQuote": true, 5 | "arrowParens": "always" 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | src 4 | !dist/src 5 | .* 6 | jest.config.js 7 | tsconfig.json 8 | extractExamples.js 9 | examples 10 | -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | [ 3 | '+([0-9])?(.{+([0-9]),x}).x', 4 | 'main', 5 | { name: 'beta', prerelease: true }, 6 | { name: 'alpha', prerelease: true }, 7 | ] 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: npm 8 | directory: / 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | restoreMocks: true, 6 | collectCoverageFrom: ['src/*.ts'], 7 | coveragePathIgnorePatterns: ['src/*.spec.ts', 'src/index.ts'], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "lib": ["ES2022", "DOM"], 5 | "target": "ES2022", 6 | "esModuleInterop": true, 7 | "outDir": "dist/src", 8 | "moduleResolution": "Bundler", 9 | "declaration": true, 10 | "emitDeclarationOnly": true 11 | }, 12 | "include": ["src/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /src/testHelpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { logKey } from './assertCacheEntry'; 2 | import { totalTtl } from './common'; 3 | 4 | describe('totalTtl helper', () => { 5 | it('handles metadata without ttl gracefully', () => { 6 | expect(totalTtl({ createdTime: 0, swr: 5 })).toBe(5); 7 | }); 8 | }); 9 | 10 | describe('internal logKey helper', () => { 11 | it('falls back to empty string, when no key given', () => { 12 | expect(logKey()).toBe(''); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | CachifiedOptions, 3 | Cache, 4 | CacheEntry, 5 | CacheMetadata, 6 | Context, 7 | GetFreshValue, 8 | GetFreshValueContext, 9 | } from './common'; 10 | export { staleWhileRevalidate, totalTtl, createCacheEntry } from './common'; 11 | export * from './reporter'; 12 | export { createBatch } from './createBatch'; 13 | export { cachified, getPendingValuesCache } from './cachified'; 14 | export { cachified as default } from './cachified'; 15 | export { shouldRefresh, isExpired } from './isExpired'; 16 | export { assertCacheEntry } from './assertCacheEntry'; 17 | export { softPurge } from './softPurge'; 18 | export { configure } from './configure'; 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (C) 2022 Hannes Diercks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/assertCacheEntry.ts: -------------------------------------------------------------------------------- 1 | import type { CacheMetadata } from './common'; 2 | 3 | export function logKey(key?: string) { 4 | return key ? `for ${key} ` : ''; 5 | } 6 | 7 | export function assertCacheEntry( 8 | entry: unknown, 9 | key?: string, 10 | ): asserts entry is { 11 | metadata: CacheMetadata; 12 | value: unknown; 13 | } { 14 | if (!isRecord(entry)) { 15 | throw new Error( 16 | `Cache entry ${logKey( 17 | key, 18 | )}is not a cache entry object, it's a ${typeof entry}`, 19 | ); 20 | } 21 | if ( 22 | !isRecord(entry.metadata) || 23 | typeof entry.metadata.createdTime !== 'number' || 24 | (entry.metadata.ttl != null && typeof entry.metadata.ttl !== 'number') || 25 | (entry.metadata.swr != null && typeof entry.metadata.swr !== 'number') 26 | ) { 27 | throw new Error( 28 | `Cache entry ${logKey(key)}does not have valid metadata property`, 29 | ); 30 | } 31 | 32 | if (!('value' in entry)) { 33 | throw new Error( 34 | `Cache entry for ${logKey(key)}does not have a value property`, 35 | ); 36 | } 37 | } 38 | 39 | function isRecord(entry: unknown): entry is Record { 40 | return typeof entry === 'object' && entry !== null && !Array.isArray(entry); 41 | } 42 | -------------------------------------------------------------------------------- /src/softPurge.ts: -------------------------------------------------------------------------------- 1 | import { Cache, createCacheEntry, staleWhileRevalidate } from './common'; 2 | import { CACHE_EMPTY, getCacheEntry } from './getCachedValue'; 3 | import { isExpired } from './isExpired'; 4 | 5 | interface SoftPurgeOpts { 6 | cache: Cache; 7 | key: string; 8 | /** 9 | * Force the entry to outdate after ms 10 | */ 11 | staleWhileRevalidate?: number; 12 | /** 13 | * Force the entry to outdate after ms 14 | */ 15 | swr?: number; 16 | } 17 | 18 | export async function softPurge({ 19 | cache, 20 | key, 21 | ...swrOverwrites 22 | }: SoftPurgeOpts) { 23 | const swrOverwrite = swrOverwrites.swr ?? swrOverwrites.staleWhileRevalidate; 24 | const entry = await getCacheEntry({ cache, key }, () => {}); 25 | 26 | if (entry === CACHE_EMPTY || isExpired(entry.metadata)) { 27 | return; 28 | } 29 | 30 | const ttl = entry.metadata.ttl || Infinity; 31 | const swr = staleWhileRevalidate(entry.metadata) || 0; 32 | const lt = Date.now() - entry.metadata.createdTime; 33 | 34 | await cache.set( 35 | key, 36 | createCacheEntry(entry.value, { 37 | ttl: 0, 38 | swr: swrOverwrite === undefined ? ttl + swr : swrOverwrite + lt, 39 | createdTime: entry.metadata.createdTime, 40 | }), 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/checkValue.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from './common'; 2 | import { MIGRATED } from './common'; 3 | 4 | export async function checkValue( 5 | context: Context, 6 | value: unknown, 7 | ): Promise< 8 | | { success: true; value: Value; migrated: boolean } 9 | | { success: false; reason: unknown } 10 | > { 11 | try { 12 | const checkResponse = await context.checkValue( 13 | value, 14 | (value, updateCache = true) => ({ 15 | [MIGRATED]: updateCache, 16 | value, 17 | }), 18 | ); 19 | 20 | if (typeof checkResponse === 'string') { 21 | return { success: false, reason: checkResponse }; 22 | } 23 | 24 | if (checkResponse == null || checkResponse === true) { 25 | return { 26 | success: true, 27 | value: value as Value, 28 | migrated: false, 29 | }; 30 | } 31 | 32 | if (checkResponse && typeof checkResponse[MIGRATED] === 'boolean') { 33 | return { 34 | success: true, 35 | migrated: checkResponse[MIGRATED], 36 | value: checkResponse.value, 37 | }; 38 | } 39 | 40 | return { success: false, reason: 'unknown' }; 41 | } catch (err) { 42 | return { 43 | success: false, 44 | reason: err, 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/isExpired.ts: -------------------------------------------------------------------------------- 1 | import { CacheMetadata, staleWhileRevalidate } from './common'; 2 | 3 | /** 4 | * Check wether a cache entry is expired. 5 | * 6 | * @returns 7 | * - `true` when the cache entry is expired 8 | * - `false` when it's still valid 9 | * - `"stale"` when it's within the stale period 10 | */ 11 | export function isExpired(metadata: CacheMetadata): boolean | 'stale' { 12 | /* No TTL means the cache is permanent / never expires */ 13 | if (metadata.ttl === null) { 14 | return false; 15 | } 16 | 17 | const validUntil = metadata.createdTime + (metadata.ttl || 0); 18 | const staleUntil = validUntil + (staleWhileRevalidate(metadata) || 0); 19 | const now = Date.now(); 20 | 21 | /* We're still within the ttl period */ 22 | if (now <= validUntil) { 23 | return false; 24 | } 25 | /* We're within the stale period */ 26 | if (now <= staleUntil) { 27 | return 'stale'; 28 | } 29 | 30 | /* Expired */ 31 | return true; 32 | } 33 | 34 | /** 35 | * @deprecated prefer using `isExpired` instead 36 | */ 37 | export function shouldRefresh( 38 | metadata: CacheMetadata, 39 | ): 'now' | 'stale' | false { 40 | const expired = isExpired(metadata); 41 | 42 | if (expired === true) { 43 | return 'now'; 44 | } 45 | 46 | return expired; 47 | } 48 | -------------------------------------------------------------------------------- /src/testHelpers.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'pretty-format'; 2 | import { CacheEvent } from './reporter'; 3 | 4 | export function prettyPrint(value: any) { 5 | return format(value, { 6 | min: true, 7 | plugins: [ 8 | { 9 | test(val) { 10 | return typeof val === 'string'; 11 | }, 12 | serialize(val, config, indentation, depth, refs) { 13 | return refs[0] && 14 | typeof refs[0] === 'object' && 15 | Object.keys(refs[refs.length - 1] as any).includes(val) 16 | ? val 17 | : `'${val}'`; 18 | }, 19 | }, 20 | ], 21 | }); 22 | } 23 | 24 | export function delay(ms: number) { 25 | return new Promise((res) => setTimeout(res, ms)); 26 | } 27 | 28 | export function report(calls: [event: CacheEvent][]) { 29 | const totalCalls = String(calls.length + 1).length; 30 | return calls 31 | .map(([{ name, ...payload }], i) => { 32 | const data = JSON.stringify(payload); 33 | const title = `${String(i + 1).padStart(totalCalls, ' ')}. ${name}`; 34 | if (!payload || data === '{}') { 35 | return title; 36 | } 37 | return `${title}\n${String('').padStart( 38 | totalCalls + 2, 39 | ' ', 40 | )}${prettyPrint(payload)}`; 41 | }) 42 | .join('\n'); 43 | } 44 | -------------------------------------------------------------------------------- /src/configure.ts: -------------------------------------------------------------------------------- 1 | import { cachified } from './cachified'; 2 | import { CachifiedOptions, CachifiedOptionsWithSchema } from './common'; 3 | import { CreateReporter, mergeReporters } from './reporter'; 4 | 5 | type PartialOptions< 6 | Options extends CachifiedOptions, 7 | OptionalKeys extends string | number | symbol, 8 | > = Omit & 9 | Partial>>; 10 | 11 | /** 12 | * create a pre-configured version of cachified 13 | */ 14 | export function configure< 15 | ConfigureValue extends unknown, 16 | Opts extends Partial>, 17 | >(defaultOptions: Opts, defaultReporter?: CreateReporter) { 18 | function configuredCachified( 19 | options: PartialOptions< 20 | CachifiedOptionsWithSchema, 21 | keyof Opts 22 | >, 23 | reporter?: CreateReporter, 24 | ): Promise; 25 | async function configuredCachified( 26 | options: PartialOptions, keyof Opts>, 27 | reporter?: CreateReporter, 28 | ): Promise; 29 | function configuredCachified( 30 | options: PartialOptions, keyof Opts>, 31 | reporter?: CreateReporter, 32 | ) { 33 | return cachified( 34 | { 35 | ...defaultOptions, 36 | ...options, 37 | } as any as CachifiedOptions, 38 | mergeReporters(defaultReporter as any as CreateReporter, reporter), 39 | ); 40 | } 41 | 42 | return configuredCachified; 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@epic-web/cachified", 3 | "version": "0.0.0-development", 4 | "description": "neat wrapper for various caches", 5 | "main": "dist/index.cjs", 6 | "module": "dist/index.mjs", 7 | "types": "dist/src/index.d.ts", 8 | "publishConfig": { 9 | "access": "public" 10 | }, 11 | "exports": { 12 | ".": { 13 | "types": "./dist/src/index.d.ts", 14 | "require": "./dist/index.cjs", 15 | "import": "./dist/index.mjs" 16 | } 17 | }, 18 | "scripts": { 19 | "prepare": "rm -rf dist && npm run build", 20 | "build": "npm run build:declarations && npm run build:esm && npm run build:cjs", 21 | "build:declarations": "tsc && rm dist/src/*.spec.d.ts; rm dist/src/testHelpers.d.ts", 22 | "build:esm": "esbuild src/index.ts --outfile=dist/index.mjs --format=esm --bundle --target=es2020 --sourcemap", 23 | "build:cjs": "esbuild src/index.ts --outfile=dist/index.cjs --format=cjs --bundle --target=es2020 --sourcemap", 24 | "test": "jest" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/epicweb-dev/cachified.git" 29 | }, 30 | "keywords": [ 31 | "cache", 32 | "wrapper", 33 | "ttl", 34 | "stale while revalidate", 35 | "typescript", 36 | "lru-cache", 37 | "redis", 38 | "typed" 39 | ], 40 | "author": "Hannes Diercks (https://xiphe.net/)", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/epicweb-dev/cachified/issues" 44 | }, 45 | "homepage": "https://github.com/epicweb-dev/cachified#readme", 46 | "devDependencies": { 47 | "@types/jest": "30.0.0", 48 | "@types/node": "25.0.1", 49 | "esbuild": "0.27.1", 50 | "jest": "30.2.0", 51 | "ts-jest": "29.4.6", 52 | "typescript": "5.9.3", 53 | "zod-legacy": "npm:zod@3.23.5" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | actions: write 14 | contents: write # to be able to publish a GitHub release 15 | id-token: write # to enable use of OIDC for npm provenance 16 | issues: write # to be able to comment on released issues 17 | pull-requests: write # to be able to comment on released pull requests 18 | 19 | defaults: 20 | run: 21 | shell: bash 22 | 23 | jobs: 24 | test: 25 | name: 🧪 Test 26 | strategy: 27 | matrix: 28 | version: [lts/-1, lts/*, latest] 29 | include: 30 | - version: lts/* 31 | coverage: true 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: ⬇️ Checkout repo 35 | uses: actions/checkout@v6 36 | 37 | - name: ⎔ Setup node 38 | uses: actions/setup-node@v6 39 | with: 40 | node-version: ${{ matrix.version }} 41 | 42 | - name: 📥 Download deps 43 | uses: bahmutov/npm-install@v1 44 | 45 | - name: 🃏 Run jest 46 | run: npm test -- --coverage=${{ matrix.coverage }} 47 | 48 | - uses: codecov/codecov-action@v5 49 | if: ${{ matrix.coverage }} 50 | 51 | publish: 52 | name: ⚙️ Release 53 | needs: [test] 54 | runs-on: ubuntu-latest 55 | permissions: 56 | contents: write # to be able to publish a GitHub release 57 | id-token: write # to enable use of OIDC for npm provenance 58 | issues: write # to be able to comment on released issues 59 | pull-requests: write # to be able to comment on released pull requests 60 | steps: 61 | - name: ⬇️ Checkout repo 62 | uses: actions/checkout@v6 63 | 64 | - name: ⎔ Setup node 65 | uses: actions/setup-node@v6 66 | with: 67 | node-version: lts/* 68 | 69 | - name: 📥 Download deps 70 | uses: bahmutov/npm-install@v1 71 | 72 | - name: ⚙️ Semantic Release 73 | uses: cycjimmy/semantic-release-action@v6.0.0 74 | with: 75 | semantic_version: 25 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | NPM_CONFIG_PROVENANCE: true 79 | -------------------------------------------------------------------------------- /src/StandardSchemaV1.ts: -------------------------------------------------------------------------------- 1 | /** The Standard Schema interface. */ 2 | export interface StandardSchemaV1 { 3 | /** The Standard Schema properties. */ 4 | readonly '~standard': StandardSchemaV1.Props; 5 | } 6 | 7 | export declare namespace StandardSchemaV1 { 8 | /** The Standard Schema properties interface. */ 9 | export interface Props { 10 | /** The version number of the standard. */ 11 | readonly version: 1; 12 | /** The vendor name of the schema library. */ 13 | readonly vendor: string; 14 | /** Validates unknown input values. */ 15 | readonly validate: ( 16 | value: unknown, 17 | ) => Result | Promise>; 18 | /** Inferred types associated with the schema. */ 19 | readonly types?: Types | undefined; 20 | } 21 | 22 | /** The result interface of the validate function. */ 23 | export type Result = SuccessResult | FailureResult; 24 | 25 | /** The result interface if validation succeeds. */ 26 | export interface SuccessResult { 27 | /** The typed output value. */ 28 | readonly value: Output; 29 | /** The non-existent issues. */ 30 | readonly issues?: undefined; 31 | } 32 | 33 | /** The result interface if validation fails. */ 34 | export interface FailureResult { 35 | /** The issues of failed validation. */ 36 | readonly issues: ReadonlyArray; 37 | } 38 | 39 | /** The issue interface of the failure output. */ 40 | export interface Issue { 41 | /** The error message of the issue. */ 42 | readonly message: string; 43 | /** The path of the issue, if any. */ 44 | readonly path?: ReadonlyArray | undefined; 45 | } 46 | 47 | /** The path segment interface of the issue. */ 48 | export interface PathSegment { 49 | /** The key representing a path segment. */ 50 | readonly key: PropertyKey; 51 | } 52 | 53 | /** The Standard Schema types interface. */ 54 | export interface Types { 55 | /** The input type of the schema. */ 56 | readonly input: Input; 57 | /** The output type of the schema. */ 58 | readonly output: Output; 59 | } 60 | 61 | /** Infers the input type of a Standard Schema. */ 62 | export type InferInput = NonNullable< 63 | Schema['~standard']['types'] 64 | >['input']; 65 | 66 | /** Infers the output type of a Standard Schema. */ 67 | export type InferOutput = NonNullable< 68 | Schema['~standard']['types'] 69 | >['output']; 70 | } 71 | -------------------------------------------------------------------------------- /src/getFreshValue.ts: -------------------------------------------------------------------------------- 1 | import { Context, CacheMetadata, createCacheEntry } from './common'; 2 | import { getCacheEntry, CACHE_EMPTY } from './getCachedValue'; 3 | import { isExpired } from './isExpired'; 4 | import { Reporter } from './reporter'; 5 | import { checkValue } from './checkValue'; 6 | 7 | export async function getFreshValue( 8 | context: Context, 9 | metadata: CacheMetadata, 10 | report: Reporter, 11 | ): Promise { 12 | const { fallbackToCache, key, getFreshValue, forceFresh, cache } = context; 13 | 14 | let value: unknown; 15 | try { 16 | report({ name: 'getFreshValueStart' }); 17 | const freshValue = await getFreshValue({ 18 | metadata: context.metadata, 19 | background: false, 20 | }); 21 | value = freshValue; 22 | report({ name: 'getFreshValueSuccess', value: freshValue }); 23 | } catch (error) { 24 | report({ name: 'getFreshValueError', error }); 25 | 26 | // in case a fresh value was forced (and errored) we might be able to 27 | // still get one from cache 28 | if (forceFresh && fallbackToCache > 0) { 29 | const entry = await getCacheEntry(context, report); 30 | if ( 31 | entry === CACHE_EMPTY || 32 | entry.metadata.createdTime + fallbackToCache < Date.now() 33 | ) { 34 | throw error; 35 | } 36 | value = entry.value; 37 | report({ name: 'getFreshValueCacheFallback', value }); 38 | } else { 39 | // we are either not allowed to check the cache or already checked it 40 | // nothing we can do anymore 41 | throw error; 42 | } 43 | } 44 | 45 | const valueCheck = await checkValue(context, value); 46 | if (!valueCheck.success) { 47 | report({ name: 'checkFreshValueErrorObj', reason: valueCheck.reason }); 48 | report({ 49 | name: 'checkFreshValueError', 50 | reason: 51 | valueCheck.reason instanceof Error 52 | ? valueCheck.reason.message 53 | : String(valueCheck.reason), 54 | }); 55 | 56 | throw new Error(`check failed for fresh value of ${key}`, { 57 | cause: valueCheck.reason, 58 | }); 59 | } 60 | 61 | try { 62 | /* Only write to cache when the value has not already fully expired while getting it */ 63 | const write = isExpired(metadata) !== true; 64 | if (write) { 65 | await cache.set(key, createCacheEntry(value, metadata)); 66 | } 67 | report({ 68 | name: 'writeFreshValueSuccess', 69 | metadata, 70 | migrated: valueCheck.migrated, 71 | written: write, 72 | }); 73 | } catch (error: unknown) { 74 | report({ name: 'writeFreshValueError', error }); 75 | } 76 | 77 | return valueCheck.value; 78 | } 79 | -------------------------------------------------------------------------------- /src/softPurge.spec.ts: -------------------------------------------------------------------------------- 1 | import { createCacheEntry } from './common'; 2 | import { softPurge } from './softPurge'; 3 | 4 | let currentTime = 0; 5 | beforeEach(() => { 6 | currentTime = 0; 7 | jest.spyOn(Date, 'now').mockImplementation(() => currentTime); 8 | }); 9 | 10 | describe('softPurge', () => { 11 | it('does not update entry when cache is outdated already', async () => { 12 | const cache = new Map(); 13 | 14 | cache.set('key', createCacheEntry('value', { ttl: 5 })); 15 | currentTime = 10; 16 | jest.spyOn(cache, 'set'); 17 | 18 | await softPurge({ cache, key: 'key' }); 19 | 20 | expect(cache.set).not.toHaveBeenCalled(); 21 | }); 22 | 23 | it('does nothing when cache is empty', async () => { 24 | const cache = new Map(); 25 | 26 | await softPurge({ cache, key: 'key' }); 27 | }); 28 | 29 | it('throws when entry is invalid', async () => { 30 | const cache = new Map(); 31 | 32 | cache.set('key', '???'); 33 | 34 | await expect( 35 | softPurge({ cache, key: 'key' }), 36 | ).rejects.toThrowErrorMatchingInlineSnapshot( 37 | `"Cache entry for key is not a cache entry object, it's a string"`, 38 | ); 39 | }); 40 | 41 | it('sets ttl to 0 and swr to previous ttl', async () => { 42 | const cache = new Map(); 43 | 44 | cache.set('key', createCacheEntry('value', { ttl: 1000 })); 45 | 46 | await softPurge({ cache, key: 'key' }); 47 | 48 | expect(cache.get('key')).toEqual( 49 | createCacheEntry('value', { ttl: 0, swr: 1000 }), 50 | ); 51 | }); 52 | 53 | it('sets ttl to 0 and swr to previous ttl + previous swr', async () => { 54 | const cache = new Map(); 55 | 56 | cache.set('key', createCacheEntry('value', { ttl: 1000, swr: 50 })); 57 | 58 | await softPurge({ cache, key: 'key' }); 59 | 60 | expect(cache.get('key')).toEqual( 61 | createCacheEntry('value', { ttl: 0, swr: 1050 }), 62 | ); 63 | }); 64 | 65 | it('sets ttl to 0 and swr to infinity when ttl was infinity', async () => { 66 | const cache = new Map(); 67 | 68 | cache.set('key', createCacheEntry('value', { ttl: Infinity })); 69 | 70 | await softPurge({ cache, key: 'key' }); 71 | 72 | expect(cache.get('key')).toEqual( 73 | createCacheEntry('value', { ttl: 0, swr: Infinity }), 74 | ); 75 | }); 76 | 77 | it('allows to set a custom stale while revalidate value', async () => { 78 | const cache = new Map(); 79 | currentTime = 30; 80 | 81 | cache.set('key', createCacheEntry('value', { ttl: Infinity })); 82 | 83 | currentTime = 40; 84 | 85 | await softPurge({ cache, key: 'key', staleWhileRevalidate: 50 }); 86 | 87 | expect(cache.get('key')).toEqual( 88 | createCacheEntry('value', { ttl: 0, swr: 60, createdTime: 30 }), 89 | ); 90 | }); 91 | 92 | it('supports swr alias', async () => { 93 | const cache = new Map(); 94 | currentTime = 30; 95 | 96 | cache.set('key', createCacheEntry('value', { ttl: Infinity })); 97 | 98 | currentTime = 55; 99 | 100 | await softPurge({ cache, key: 'key', swr: 10 }); 101 | 102 | expect(cache.get('key')).toEqual( 103 | createCacheEntry('value', { ttl: 0, swr: 35, createdTime: 30 }), 104 | ); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/cachified.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CachifiedOptions, 3 | CachifiedOptionsWithSchema, 4 | Cache, 5 | createContext, 6 | HANDLE, 7 | } from './common'; 8 | import { CACHE_EMPTY, getCachedValue } from './getCachedValue'; 9 | import { getFreshValue } from './getFreshValue'; 10 | import { CreateReporter } from './reporter'; 11 | import { isExpired } from './isExpired'; 12 | 13 | // This is to prevent requesting multiple fresh values in parallel 14 | // while revalidating or getting first value 15 | // Keys are unique per cache but may be used by multiple caches 16 | const pendingValuesByCache = new WeakMap>(); 17 | 18 | /** 19 | * Get the internal pending values cache for a given cache 20 | */ 21 | export function getPendingValuesCache(cache: Cache) { 22 | if (!pendingValuesByCache.has(cache)) { 23 | pendingValuesByCache.set(cache, new Map()); 24 | } 25 | return pendingValuesByCache.get(cache)!; 26 | } 27 | 28 | export async function cachified( 29 | options: CachifiedOptionsWithSchema, 30 | reporter?: CreateReporter, 31 | ): Promise; 32 | export async function cachified( 33 | options: CachifiedOptions, 34 | reporter?: CreateReporter, 35 | ): Promise; 36 | export async function cachified( 37 | options: CachifiedOptions, 38 | reporter?: CreateReporter, 39 | ): Promise { 40 | const context = createContext(options, reporter); 41 | const { key, cache, forceFresh, report, metadata } = context; 42 | const pendingValues = getPendingValuesCache(cache); 43 | 44 | const hasPendingValue = () => { 45 | return pendingValues.has(key); 46 | }; 47 | const cachedValue = !forceFresh 48 | ? await getCachedValue(context, report, hasPendingValue) 49 | : CACHE_EMPTY; 50 | if (cachedValue !== CACHE_EMPTY) { 51 | report({ name: 'done', value: cachedValue }); 52 | return cachedValue; 53 | } 54 | 55 | if (pendingValues.has(key)) { 56 | const { value: pendingRefreshValue, metadata } = pendingValues.get(key)!; 57 | 58 | if (!isExpired(metadata)) { 59 | /* Notify batch that we handled this call using pending value */ 60 | context.getFreshValue[HANDLE]?.(); 61 | report({ name: 'getFreshValueHookPending' }); 62 | const value = await pendingRefreshValue; 63 | report({ name: 'done', value }); 64 | return value; 65 | } 66 | } 67 | 68 | let resolveFromFuture: (value: Value) => void; 69 | const freshValue = Promise.race([ 70 | // try to get a fresh value 71 | getFreshValue(context, metadata, report), 72 | // or when a future call is faster, we'll take it's value 73 | // this happens when getting value of first call takes longer then ttl + second response 74 | new Promise((r) => { 75 | resolveFromFuture = r; 76 | }), 77 | ]).finally(() => { 78 | pendingValues.delete(key); 79 | }); 80 | 81 | // here we inform past calls that we got a response 82 | if (pendingValues.has(key)) { 83 | const { resolve } = pendingValues.get(key)!; 84 | freshValue.then((value) => resolve(value)); 85 | } 86 | 87 | pendingValues.set(key, { 88 | metadata, 89 | value: freshValue, 90 | // here we receive a fresh value from a future call 91 | resolve: resolveFromFuture!, 92 | }); 93 | 94 | const value = await freshValue; 95 | report({ name: 'done', value }); 96 | return value; 97 | } 98 | -------------------------------------------------------------------------------- /src/createBatch.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CacheMetadata, 3 | GetFreshValue, 4 | GetFreshValueContext, 5 | } from './common'; 6 | import { HANDLE } from './common'; 7 | 8 | type OnValueCallback = ( 9 | context: GetFreshValueContext & { 10 | value: Value; 11 | }, 12 | ) => void; 13 | 14 | export type AddFn = ( 15 | param: Param, 16 | onValue?: OnValueCallback, 17 | ) => GetFreshValue; 18 | 19 | export type GetFreshValues = ( 20 | params: Param[], 21 | metadata: CacheMetadata[], 22 | ) => Value[] | Promise; 23 | 24 | export function createBatch( 25 | getFreshValues: GetFreshValues, 26 | autoSubmit: false, 27 | ): { 28 | submit: () => Promise; 29 | add: AddFn; 30 | }; 31 | export function createBatch( 32 | getFreshValues: GetFreshValues, 33 | ): { 34 | add: AddFn; 35 | }; 36 | export function createBatch( 37 | getFreshValues: GetFreshValues, 38 | autoSubmit: boolean = true, 39 | ): { 40 | submit?: () => Promise; 41 | add: AddFn; 42 | } { 43 | const requests: [ 44 | param: Param, 45 | res: (value: Value) => void, 46 | rej: (reason: unknown) => void, 47 | metadata: CacheMetadata, 48 | ][] = []; 49 | 50 | let count = 0; 51 | let submitted = false; 52 | const submission = new Deferred(); 53 | 54 | const checkSubmission = () => { 55 | if (submitted) { 56 | throw new Error('Can not add to batch after submission'); 57 | } 58 | }; 59 | 60 | const submit = async () => { 61 | if (count !== 0) { 62 | autoSubmit = true; 63 | return submission.promise; 64 | } 65 | checkSubmission(); 66 | submitted = true; 67 | 68 | if (requests.length === 0) { 69 | submission.resolve(); 70 | return; 71 | } 72 | 73 | try { 74 | const results = await Promise.resolve( 75 | getFreshValues( 76 | requests.map(([param]) => param), 77 | requests.map((args) => args[3]), 78 | ), 79 | ); 80 | results.forEach((value, index) => requests[index][1](value)); 81 | submission.resolve(); 82 | } catch (err) { 83 | requests.forEach(([_, __, rej]) => rej(err)); 84 | submission.resolve(); 85 | } 86 | }; 87 | 88 | const trySubmitting = () => { 89 | count--; 90 | if (autoSubmit === false) { 91 | return; 92 | } 93 | submit(); 94 | }; 95 | 96 | return { 97 | ...(autoSubmit === false ? { submit } : {}), 98 | add(param, onValue) { 99 | checkSubmission(); 100 | count++; 101 | let handled = false; 102 | 103 | return Object.assign( 104 | (context: GetFreshValueContext) => { 105 | return new Promise((res, rej) => { 106 | requests.push([ 107 | param, 108 | (value) => { 109 | onValue?.({ ...context, value }); 110 | res(value); 111 | }, 112 | rej, 113 | context.metadata, 114 | ]); 115 | if (!handled) { 116 | handled = true; 117 | trySubmitting(); 118 | } 119 | }); 120 | }, 121 | { 122 | [HANDLE]: () => { 123 | if (!handled) { 124 | handled = true; 125 | trySubmitting(); 126 | } 127 | }, 128 | }, 129 | ); 130 | }, 131 | }; 132 | } 133 | 134 | export class Deferred { 135 | readonly promise: Promise; 136 | // @ts-ignore 137 | readonly resolve: (value: Value | Promise) => void; 138 | // @ts-ignore 139 | readonly reject: (reason: unknown) => void; 140 | constructor() { 141 | this.promise = new Promise((res, rej) => { 142 | // @ts-ignore 143 | this.resolve = res; 144 | // @ts-ignore 145 | this.reject = rej; 146 | }); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/getCachedValue.ts: -------------------------------------------------------------------------------- 1 | import { Context, CacheEntry, CachifiedOptions } from './common'; 2 | import { assertCacheEntry } from './assertCacheEntry'; 3 | import { HANDLE } from './common'; 4 | import { isExpired } from './isExpired'; 5 | import { cachified } from './cachified'; 6 | import { Reporter } from './reporter'; 7 | import { checkValue } from './checkValue'; 8 | 9 | export const CACHE_EMPTY = Symbol(); 10 | export async function getCacheEntry( 11 | { key, cache }: Pick, 'key' | 'cache'>, 12 | report: Reporter, 13 | ): Promise | typeof CACHE_EMPTY> { 14 | report({ name: 'getCachedValueStart' }); 15 | const cached = await cache.get(key); 16 | report({ name: 'getCachedValueRead', entry: cached }); 17 | if (cached) { 18 | assertCacheEntry(cached, key); 19 | return cached; 20 | } 21 | return CACHE_EMPTY; 22 | } 23 | 24 | export async function getCachedValue( 25 | context: Context, 26 | report: Reporter, 27 | hasPendingValue: () => boolean, 28 | ): Promise { 29 | const { 30 | key, 31 | cache, 32 | staleWhileRevalidate, 33 | staleRefreshTimeout, 34 | metadata, 35 | getFreshValue, 36 | } = context; 37 | 38 | try { 39 | const cached = await getCacheEntry(context, report); 40 | 41 | if (cached === CACHE_EMPTY) { 42 | report({ name: 'getCachedValueEmpty' }); 43 | return CACHE_EMPTY; 44 | } 45 | 46 | const expired = isExpired(cached.metadata); 47 | const staleRefresh = 48 | expired === 'stale' || 49 | (expired === true && staleWhileRevalidate === Infinity); 50 | 51 | if (expired === true) { 52 | report({ name: 'getCachedValueOutdated', ...cached }); 53 | } 54 | 55 | if (staleRefresh) { 56 | const staleRefreshOptions: CachifiedOptions = { 57 | ...context, 58 | async getFreshValue({ metadata }) { 59 | /* TODO: When staleRefreshTimeout option is removed we should 60 | also remove this or set it to ~0-200ms depending on ttl values. 61 | The intention of the delay is to not take sync resources for 62 | background refreshing – still we need to queue the refresh 63 | directly so that the de-duplication works. 64 | See https://github.com/epicweb-dev/cachified/issues/132 */ 65 | await sleep(staleRefreshTimeout); 66 | report({ name: 'refreshValueStart' }); 67 | return getFreshValue({ 68 | metadata, 69 | background: true, 70 | }); 71 | }, 72 | forceFresh: true, 73 | fallbackToCache: false, 74 | }; 75 | 76 | // pass down batch handle when present 77 | // https://github.com/epicweb-dev/cachified/issues/144 78 | staleRefreshOptions.getFreshValue[HANDLE] = context.getFreshValue[HANDLE]; 79 | 80 | // refresh cache in background so future requests are faster 81 | context.waitUntil( 82 | cachified(staleRefreshOptions) 83 | .then((value) => { 84 | report({ name: 'refreshValueSuccess', value }); 85 | }) 86 | .catch((error) => { 87 | report({ name: 'refreshValueError', error }); 88 | }), 89 | ); 90 | } 91 | 92 | if (!expired || staleRefresh) { 93 | const valueCheck = await checkValue(context, cached.value); 94 | if (valueCheck.success) { 95 | report({ 96 | name: 'getCachedValueSuccess', 97 | value: valueCheck.value, 98 | migrated: valueCheck.migrated, 99 | }); 100 | if (!staleRefresh) { 101 | // Notify batch that we handled this call using cached value 102 | getFreshValue[HANDLE]?.(); 103 | } 104 | 105 | if (valueCheck.migrated) { 106 | context.waitUntil( 107 | Promise.resolve().then(async () => { 108 | try { 109 | await sleep(0); // align with original setTimeout behavior (allowing other microtasks/tasks to run) 110 | const cached = await context.cache.get(context.key); 111 | 112 | // Unless cached value was changed in the meantime or is about to 113 | // change 114 | if ( 115 | cached && 116 | cached.metadata.createdTime === metadata.createdTime && 117 | !hasPendingValue() 118 | ) { 119 | // update with migrated value 120 | await context.cache.set(context.key, { 121 | ...cached, 122 | value: valueCheck.value, 123 | }); 124 | } 125 | } catch (err) { 126 | /* ¯\_(ツ)_/¯ */ 127 | } 128 | }), 129 | ); 130 | } 131 | 132 | return valueCheck.value; 133 | } else { 134 | report({ name: 'checkCachedValueErrorObj', reason: valueCheck.reason }); 135 | report({ 136 | name: 'checkCachedValueError', 137 | reason: 138 | valueCheck.reason instanceof Error 139 | ? valueCheck.reason.message 140 | : String(valueCheck.reason), 141 | }); 142 | 143 | await cache.delete(key); 144 | } 145 | } 146 | } catch (error: unknown) { 147 | report({ name: 'getCachedValueError', error }); 148 | 149 | await cache.delete(key); 150 | } 151 | 152 | return CACHE_EMPTY; 153 | } 154 | 155 | function sleep(ms: number) { 156 | return new Promise((resolve) => setTimeout(resolve, ms)); 157 | } 158 | -------------------------------------------------------------------------------- /src/reporter.ts: -------------------------------------------------------------------------------- 1 | import { CacheMetadata, Context, staleWhileRevalidate } from './common'; 2 | 3 | export type GetFreshValueStartEvent = { 4 | name: 'getFreshValueStart'; 5 | }; 6 | export type GetFreshValueHookPendingEvent = { 7 | name: 'getFreshValueHookPending'; 8 | }; 9 | export type GetFreshValueSuccessEvent = { 10 | name: 'getFreshValueSuccess'; 11 | value: Value; 12 | }; 13 | export type GetFreshValueErrorEvent = { 14 | name: 'getFreshValueError'; 15 | error: unknown; 16 | }; 17 | export type GetFreshValueCacheFallbackEvent = { 18 | name: 'getFreshValueCacheFallback'; 19 | value: unknown; 20 | }; 21 | /** @deprecated this event will be removed in favour of `CheckFreshValueErrorObjEvent` */ 22 | export type CheckFreshValueErrorEvent = { 23 | name: 'checkFreshValueError'; 24 | reason: string; 25 | }; 26 | export type CheckFreshValueErrorObjEvent = { 27 | name: 'checkFreshValueErrorObj'; 28 | reason: unknown; 29 | }; 30 | export type WriteFreshValueSuccessEvent = { 31 | name: 'writeFreshValueSuccess'; 32 | metadata: CacheMetadata; 33 | /** 34 | * Value might not actually be written to cache in case getting fresh 35 | * value took longer then ttl */ 36 | written: boolean; 37 | migrated: boolean; 38 | }; 39 | export type WriteFreshValueErrorEvent = { 40 | name: 'writeFreshValueError'; 41 | error: unknown; 42 | }; 43 | 44 | export type GetCachedValueStartEvent = { 45 | name: 'getCachedValueStart'; 46 | }; 47 | export type GetCachedValueReadEvent = { 48 | name: 'getCachedValueRead'; 49 | entry: unknown; 50 | }; 51 | export type GetCachedValueEmptyEvent = { 52 | name: 'getCachedValueEmpty'; 53 | }; 54 | export type GetCachedValueOutdatedEvent = { 55 | name: 'getCachedValueOutdated'; 56 | value: unknown; 57 | metadata: CacheMetadata; 58 | }; 59 | export type GetCachedValueSuccessEvent = { 60 | name: 'getCachedValueSuccess'; 61 | value: Value; 62 | migrated: boolean; 63 | }; 64 | /** @deprecated this event will be removed in favour of `CheckCachedValueErrorObjEvent` */ 65 | export type CheckCachedValueErrorEvent = { 66 | name: 'checkCachedValueError'; 67 | reason: string; 68 | }; 69 | export type CheckCachedValueErrorObjEvent = { 70 | name: 'checkCachedValueErrorObj'; 71 | reason: unknown; 72 | }; 73 | export type GetCachedValueErrorEvent = { 74 | name: 'getCachedValueError'; 75 | error: unknown; 76 | }; 77 | 78 | export type RefreshValueStartEvent = { 79 | name: 'refreshValueStart'; 80 | }; 81 | export type RefreshValueSuccessEvent = { 82 | name: 'refreshValueSuccess'; 83 | value: Value; 84 | }; 85 | export type RefreshValueErrorEvent = { 86 | name: 'refreshValueError'; 87 | error: unknown; 88 | }; 89 | export type DoneEvent = { 90 | name: 'done'; 91 | value: Value; 92 | }; 93 | 94 | export type CacheEvent = 95 | | GetFreshValueStartEvent 96 | | GetFreshValueHookPendingEvent 97 | | GetFreshValueSuccessEvent 98 | | GetFreshValueErrorEvent 99 | | GetFreshValueCacheFallbackEvent 100 | | CheckFreshValueErrorEvent 101 | | CheckFreshValueErrorObjEvent 102 | | WriteFreshValueSuccessEvent 103 | | WriteFreshValueErrorEvent 104 | | GetCachedValueStartEvent 105 | | GetCachedValueReadEvent 106 | | GetCachedValueEmptyEvent 107 | | GetCachedValueOutdatedEvent 108 | | GetCachedValueSuccessEvent 109 | | CheckCachedValueErrorEvent 110 | | CheckCachedValueErrorObjEvent 111 | | GetCachedValueErrorEvent 112 | | RefreshValueStartEvent 113 | | RefreshValueSuccessEvent 114 | | RefreshValueErrorEvent 115 | | DoneEvent; 116 | 117 | export type Reporter = (event: CacheEvent) => void; 118 | 119 | export type CreateReporter = ( 120 | context: Omit, 'report'>, 121 | ) => Reporter; 122 | 123 | const defaultFormatDuration = (ms: number) => `${Math.round(ms)}ms`; 124 | function formatCacheTime( 125 | metadata: CacheMetadata, 126 | formatDuration: (duration: number) => string, 127 | ) { 128 | const swr = staleWhileRevalidate(metadata); 129 | if (metadata.ttl == null || swr == null) { 130 | return `forever${ 131 | metadata.ttl != null 132 | ? ` (revalidation after ${formatDuration(metadata.ttl)})` 133 | : '' 134 | }`; 135 | } 136 | 137 | return `${formatDuration(metadata.ttl)} + ${formatDuration(swr)} stale`; 138 | } 139 | 140 | export type NoInfer = [T][T extends any ? 0 : never]; 141 | interface ReporterOpts { 142 | formatDuration?: (ms: number) => string; 143 | logger?: Pick; 144 | performance?: Pick; 145 | } 146 | export function verboseReporter({ 147 | formatDuration = defaultFormatDuration, 148 | logger = console, 149 | performance = globalThis.performance || Date, 150 | }: ReporterOpts = {}): CreateReporter { 151 | return ({ key, fallbackToCache, forceFresh, metadata, cache }) => { 152 | const cacheName = 153 | cache.name || 154 | cache 155 | .toString() 156 | .toString() 157 | .replace(/^\[object (.*?)]$/, '$1'); 158 | let cached: unknown; 159 | let freshValue: unknown; 160 | let getFreshValueStartTs: number; 161 | let refreshValueStartTS: number; 162 | 163 | return (event) => { 164 | switch (event.name) { 165 | case 'getCachedValueRead': 166 | cached = event.entry; 167 | break; 168 | case 'checkCachedValueError': 169 | logger.warn( 170 | `check failed for cached value of ${key}\nReason: ${event.reason}.\nDeleting the cache key and trying to get a fresh value.`, 171 | cached, 172 | ); 173 | break; 174 | case 'getCachedValueError': 175 | logger.error( 176 | `error with cache at ${key}. Deleting the cache key and trying to get a fresh value.`, 177 | event.error, 178 | ); 179 | break; 180 | case 'getFreshValueError': 181 | logger.error( 182 | `getting a fresh value for ${key} failed`, 183 | { fallbackToCache, forceFresh }, 184 | event.error, 185 | ); 186 | break; 187 | case 'getFreshValueStart': 188 | getFreshValueStartTs = performance.now(); 189 | break; 190 | case 'writeFreshValueSuccess': { 191 | const totalTime = performance.now() - getFreshValueStartTs; 192 | if (event.written) { 193 | logger.log( 194 | `Updated the cache value for ${key}.`, 195 | `Getting a fresh value for this took ${formatDuration( 196 | totalTime, 197 | )}.`, 198 | `Caching for ${formatCacheTime( 199 | metadata, 200 | formatDuration, 201 | )} in ${cacheName}.`, 202 | ); 203 | } else { 204 | logger.log( 205 | `Not updating the cache value for ${key}.`, 206 | `Getting a fresh value for this took ${formatDuration( 207 | totalTime, 208 | )}.`, 209 | `Thereby exceeding caching time of ${formatCacheTime( 210 | metadata, 211 | formatDuration, 212 | )}`, 213 | ); 214 | } 215 | break; 216 | } 217 | case 'writeFreshValueError': 218 | logger.error(`error setting cache: ${key}`, event.error); 219 | break; 220 | case 'getFreshValueSuccess': 221 | freshValue = event.value; 222 | break; 223 | case 'checkFreshValueError': 224 | logger.error( 225 | `check failed for fresh value of ${key}\nReason: ${event.reason}.`, 226 | freshValue, 227 | ); 228 | break; 229 | case 'refreshValueStart': 230 | refreshValueStartTS = performance.now(); 231 | break; 232 | case 'refreshValueSuccess': 233 | logger.log( 234 | `Background refresh for ${key} successful.`, 235 | `Getting a fresh value for this took ${formatDuration( 236 | performance.now() - refreshValueStartTS, 237 | )}.`, 238 | `Caching for ${formatCacheTime( 239 | metadata, 240 | formatDuration, 241 | )} in ${cacheName}.`, 242 | ); 243 | break; 244 | case 'refreshValueError': 245 | logger.error(`Background refresh for ${key} failed.`, event.error); 246 | break; 247 | } 248 | }; 249 | }; 250 | } 251 | 252 | export function mergeReporters( 253 | ...reporters: (CreateReporter | null | undefined)[] 254 | ): CreateReporter { 255 | return (context) => { 256 | const reporter = reporters.map((r) => r?.(context)); 257 | return (event) => { 258 | reporter.forEach((r) => r?.(event)); 259 | }; 260 | }; 261 | } 262 | -------------------------------------------------------------------------------- /src/reporter.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cachified, 3 | CacheEntry, 4 | verboseReporter, 5 | createCacheEntry, 6 | mergeReporters, 7 | } from './index'; 8 | import { delay, prettyPrint } from './testHelpers'; 9 | 10 | jest.mock('./index', () => { 11 | if (process.version.startsWith('v20')) { 12 | return jest.requireActual('./index'); 13 | } else { 14 | console.log('⚠️ Running Tests against dist/index.cjs'); 15 | return require('../dist/index.cjs'); 16 | } 17 | }); 18 | 19 | let currentTime = 0; 20 | beforeEach(() => { 21 | currentTime = 0; 22 | jest.spyOn(Date, 'now').mockImplementation(() => currentTime); 23 | }); 24 | 25 | describe('verbose reporter', () => { 26 | it('logs when cached value is invalid', async () => { 27 | const cache = new Map(); 28 | const logger = createLogger(); 29 | cache.set('test', createCacheEntry('One')); 30 | 31 | const result = await cachified( 32 | { 33 | cache, 34 | key: 'test', 35 | checkValue: (v) => (v !== 'VALUE' ? '🚔' : true), 36 | getFreshValue() { 37 | return 'VALUE' as const; 38 | }, 39 | }, 40 | verboseReporter({ logger, performance: Date }), 41 | ); 42 | 43 | // ensure correct type of result 44 | // ref https://github.com/epicweb-dev/cachified/issues/70 45 | const _: 'VALUE' = result; 46 | 47 | expect(logger.print()).toMatchInlineSnapshot(` 48 | "WARN: 'check failed for cached value of test 49 | Reason: 🚔. 50 | Deleting the cache key and trying to get a fresh value.' {metadata: {createdTime: 0, swr: 0, ttl: null}, value: 'One'} 51 | LOG: 'Updated the cache value for test.' 'Getting a fresh value for this took 0ms.' 'Caching for forever in Map.'" 52 | `); 53 | }); 54 | 55 | it('logs when getting a cached value fails', async () => { 56 | const cache = new Map(); 57 | const logger = createLogger(); 58 | const getMock = jest.spyOn(cache, 'get'); 59 | getMock.mockImplementationOnce(() => { 60 | throw new Error('💥'); 61 | }); 62 | 63 | await cachified( 64 | { 65 | cache, 66 | key: 'test', 67 | ttl: 50, 68 | staleWhileRevalidate: Infinity, 69 | getFreshValue: () => 'VALUE', 70 | }, 71 | verboseReporter({ logger, performance: Date }), 72 | ); 73 | 74 | expect(logger.print()).toMatchInlineSnapshot(` 75 | "ERROR: 'error with cache at test. Deleting the cache key and trying to get a fresh value.' [Error: 💥] 76 | LOG: 'Updated the cache value for test.' 'Getting a fresh value for this took 0ms.' 'Caching for forever (revalidation after 50ms) in Map.'" 77 | `); 78 | }); 79 | 80 | it('logs when getting a fresh value fails', async () => { 81 | const cache = new Map(); 82 | const logger = createLogger(); 83 | 84 | await cachified( 85 | { 86 | cache, 87 | key: 'test', 88 | getFreshValue: () => { 89 | throw new Error('⁇'); 90 | }, 91 | }, 92 | verboseReporter({ logger, performance: Date }), 93 | ).catch(() => { 94 | /* ¯\_(ツ)_/¯ */ 95 | }); 96 | 97 | expect(logger.print()).toMatchInlineSnapshot( 98 | `"ERROR: 'getting a fresh value for test failed' {fallbackToCache: Infinity, forceFresh: false} [Error: ⁇]"`, 99 | ); 100 | }); 101 | 102 | it('logs when fresh value is not written to cache', async () => { 103 | const cache = new Map(); 104 | const logger = createLogger(); 105 | 106 | await cachified( 107 | { 108 | cache, 109 | key: 'test', 110 | ttl: 5, 111 | staleWhileRevalidate: 5, 112 | getFreshValue: () => { 113 | currentTime = 20; 114 | return 'ONE'; 115 | }, 116 | }, 117 | verboseReporter({ logger, performance: Date }), 118 | ); 119 | 120 | expect(logger.print()).toMatchInlineSnapshot( 121 | `"LOG: 'Not updating the cache value for test.' 'Getting a fresh value for this took 20ms.' 'Thereby exceeding caching time of 5ms + 5ms stale'"`, 122 | ); 123 | }); 124 | 125 | it('logs when writing to cache fails (using defaults)', async () => { 126 | const cache = new Map(); 127 | const errorMock = jest.spyOn(console, 'error').mockImplementation(() => { 128 | /* 🤫 */ 129 | }); 130 | jest.spyOn(cache, 'set').mockImplementationOnce(() => { 131 | throw new Error('⚡️'); 132 | }); 133 | 134 | await cachified( 135 | { 136 | cache, 137 | key: 'test', 138 | getFreshValue: () => 'ONE', 139 | }, 140 | verboseReporter(), 141 | ); 142 | 143 | expect(errorMock.mock.calls).toMatchInlineSnapshot(` 144 | [ 145 | [ 146 | "error setting cache: test", 147 | [Error: ⚡️], 148 | ], 149 | ] 150 | `); 151 | }); 152 | 153 | it('falls back to Date when performance is not globally available', async () => { 154 | const backup = globalThis.performance; 155 | delete (globalThis as any).performance; 156 | const cache = new Map(); 157 | const logger = createLogger(); 158 | 159 | await cachified( 160 | { 161 | cache, 162 | key: 'test', 163 | getFreshValue: () => 'ONE', 164 | }, 165 | verboseReporter({ logger }), 166 | ); 167 | 168 | (globalThis as any).performance = backup; 169 | expect(Date.now).toHaveBeenCalledTimes(3); 170 | }); 171 | 172 | it('logs when fresh value does not meet value check', async () => { 173 | const cache = new Map(); 174 | const logger = createLogger(); 175 | 176 | await cachified( 177 | { 178 | cache, 179 | key: 'test', 180 | checkValue: () => false, 181 | getFreshValue: () => 'ONE', 182 | }, 183 | verboseReporter({ logger, performance: Date }), 184 | ).catch(() => { 185 | /* 🤷 */ 186 | }); 187 | 188 | expect(logger.print()).toMatchInlineSnapshot(` 189 | "ERROR: 'check failed for fresh value of test 190 | Reason: unknown.' 'ONE'" 191 | `); 192 | }); 193 | 194 | it('logs when cache is successfully revalidated', async () => { 195 | const cache = new Map(); 196 | const logger = createLogger(); 197 | cache.set('test', createCacheEntry('ONE', { ttl: 5, swr: 10 })); 198 | currentTime = 7; 199 | 200 | await cachified( 201 | { 202 | cache, 203 | key: 'test', 204 | getFreshValue: () => { 205 | currentTime = 10; 206 | return 'TWO'; 207 | }, 208 | }, 209 | verboseReporter({ logger, performance: Date }), 210 | ); 211 | 212 | await delay(0); 213 | expect(logger.print()).toMatchInlineSnapshot( 214 | `"LOG: 'Background refresh for test successful.' 'Getting a fresh value for this took 3ms.' 'Caching for forever in Map.'"`, 215 | ); 216 | }); 217 | 218 | it('logs when cache revalidation fails', async () => { 219 | const cache = new Map(); 220 | const logger = createLogger(); 221 | cache.set('test', createCacheEntry('ONE', { ttl: 5, swr: 10 })); 222 | currentTime = 7; 223 | 224 | await cachified( 225 | { 226 | cache, 227 | key: 'test', 228 | getFreshValue: () => { 229 | currentTime = 10; 230 | throw new Error('🧨'); 231 | }, 232 | }, 233 | verboseReporter({ logger, performance: Date }), 234 | ); 235 | 236 | await delay(0); 237 | expect(logger.print()).toMatchInlineSnapshot( 238 | `"ERROR: 'Background refresh for test failed.' [Error: 🧨]"`, 239 | ); 240 | }); 241 | }); 242 | 243 | describe('mergeReporters', () => { 244 | it('merges multiple reporters', async () => { 245 | const cache = new Map(); 246 | const logger1 = createLogger(); 247 | const logger2 = createLogger(); 248 | 249 | await cachified( 250 | { 251 | cache, 252 | key: 'test', 253 | getFreshValue() { 254 | return 'ONE'; 255 | }, 256 | }, 257 | mergeReporters( 258 | verboseReporter({ logger: logger1, performance: Date }), 259 | undefined, 260 | verboseReporter({ logger: logger2, performance: Date }), 261 | ), 262 | ); 263 | 264 | expect(logger1.print()).toMatchInlineSnapshot( 265 | `"LOG: 'Updated the cache value for test.' 'Getting a fresh value for this took 0ms.' 'Caching for forever in Map.'"`, 266 | ); 267 | expect(logger2.print()).toMatchInlineSnapshot( 268 | `"LOG: 'Updated the cache value for test.' 'Getting a fresh value for this took 0ms.' 'Caching for forever in Map.'"`, 269 | ); 270 | }); 271 | }); 272 | 273 | function createLogger() { 274 | const log: string[] = []; 275 | 276 | return { 277 | log(...args: any[]) { 278 | log.push( 279 | args 280 | .reduce((m, v) => `${m} ${prettyPrint(v)}`, 'LOG:') 281 | .replace(/\n/g, '\n '), 282 | ); 283 | }, 284 | warn(...args: any[]) { 285 | log.push( 286 | args 287 | .reduce((m, v) => `${m} ${prettyPrint(v)}`, 'WARN:') 288 | .replace(/\n/g, '\n '), 289 | ); 290 | }, 291 | error(...args: any[]) { 292 | log.push( 293 | args 294 | .reduce((m, v) => `${m} ${prettyPrint(v)}`, 'ERROR:') 295 | .replace(/\n/g, '\n '), 296 | ); 297 | }, 298 | print() { 299 | return log.join('\n'); 300 | }, 301 | }; 302 | } 303 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import type { CreateReporter, Reporter } from './reporter'; 2 | import { StandardSchemaV1 } from './StandardSchemaV1'; 3 | 4 | export interface CacheMetadata { 5 | createdTime: number; 6 | ttl?: number | null; 7 | swr?: number | null; 8 | traceId?: any; 9 | /** @deprecated use swr instead */ 10 | readonly swv?: number | null; 11 | } 12 | 13 | export interface CacheEntry { 14 | metadata: CacheMetadata; 15 | value: Value; 16 | } 17 | 18 | export type Eventually = 19 | | Value 20 | | null 21 | | undefined 22 | | Promise; 23 | 24 | export interface Cache { 25 | name?: string; 26 | get: (key: string) => Eventually>; 27 | set: (key: string, value: CacheEntry) => unknown | Promise; 28 | delete: (key: string) => unknown | Promise; 29 | } 30 | 31 | export interface GetFreshValueContext { 32 | readonly metadata: CacheMetadata; 33 | readonly background: boolean; 34 | } 35 | export const HANDLE = Symbol(); 36 | export type GetFreshValue = { 37 | (context: GetFreshValueContext): Promise | Value; 38 | [HANDLE]?: () => void; 39 | }; 40 | export const MIGRATED = Symbol(); 41 | export type MigratedValue = { 42 | [MIGRATED]: boolean; 43 | value: Value; 44 | }; 45 | 46 | export type ValueCheckResultOk = 47 | | true 48 | | undefined 49 | | null 50 | | void 51 | | MigratedValue; 52 | export type ValueCheckResultInvalid = false | string; 53 | export type ValueCheckResult = 54 | | ValueCheckResultOk 55 | | ValueCheckResultInvalid; 56 | 57 | export type CheckValue = ( 58 | value: unknown, 59 | migrate: (value: Value, updateCache?: boolean) => MigratedValue, 60 | ) => ValueCheckResult | Promise>; 61 | 62 | /** 63 | * @deprecated use a library supporting Standard Schema 64 | * @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec 65 | * @todo remove in next major version 66 | */ 67 | export interface Schema { 68 | _input: InputValue; 69 | parseAsync(value: unknown): Promise; 70 | } 71 | 72 | export interface CachifiedOptions { 73 | /** 74 | * Required 75 | * 76 | * The key this value is cached by 77 | * Must be unique for each value 78 | */ 79 | key: string; 80 | /** 81 | * Required 82 | * 83 | * Cache implementation to use 84 | * 85 | * Must conform with signature 86 | * - set(key: string, value: object): void | Promise 87 | * - get(key: string): object | Promise 88 | * - delete(key: string): void | Promise 89 | */ 90 | cache: Cache; 91 | /** 92 | * Required 93 | * 94 | * Function that is called when no valid value is in cache for given key 95 | * Basically what we would do if we wouldn't use a cache 96 | * 97 | * Can be async and must return fresh value or throw 98 | * 99 | * receives context object as argument 100 | * - context.metadata.ttl?: number 101 | * - context.metadata.swr?: number 102 | * - context.metadata.createdTime: number 103 | * - context.background: boolean 104 | */ 105 | getFreshValue: GetFreshValue; 106 | /** 107 | * Time To Live; often also referred to as max age 108 | * 109 | * Amount of milliseconds the value should stay in cache 110 | * before we get a fresh one 111 | * 112 | * Setting any negative value will disable caching 113 | * Can be infinite 114 | * 115 | * Default: `Infinity` 116 | */ 117 | ttl?: number; 118 | /** 119 | * Amount of milliseconds that a value with exceeded ttl is still returned 120 | * while a fresh value is refreshed in the background 121 | * 122 | * Should be positive, can be infinite 123 | * 124 | * Default: `0` 125 | */ 126 | staleWhileRevalidate?: number; 127 | /** 128 | * Alias for staleWhileRevalidate 129 | */ 130 | swr?: number; 131 | /** 132 | * Validator that checks every cached and fresh value to ensure type safety 133 | * 134 | * Can be a standard schema validator or a custom validator function 135 | * @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec 136 | * 137 | * Value considered ok when: 138 | * - schema succeeds 139 | * - validator returns 140 | * - true 141 | * - migrate(newValue) 142 | * - undefined 143 | * - null 144 | * 145 | * Value considered bad when: 146 | * - schema throws 147 | * - validator: 148 | * - returns false 149 | * - returns reason as string 150 | * - throws 151 | * 152 | * A validator function receives two arguments: 153 | * 1. the value 154 | * 2. a migrate callback, see https://github.com/epicweb-dev/cachified#migrating-values 155 | * 156 | * Default: `undefined` - no validation 157 | */ 158 | checkValue?: 159 | | CheckValue 160 | | StandardSchemaV1 161 | | Schema; 162 | /** 163 | * Set true to not even try reading the currently cached value 164 | * 165 | * Will write new value to cache even when cached value is 166 | * still valid. 167 | * 168 | * Default: `false` 169 | */ 170 | forceFresh?: boolean; 171 | /** 172 | * Whether or not to fall back to cache when getting a forced fresh value 173 | * fails 174 | * 175 | * Can also be a positive number as the maximum age in milliseconds that a 176 | * fallback value might have 177 | * 178 | * Default: `Infinity` 179 | */ 180 | fallbackToCache?: boolean | number; 181 | /** 182 | * Amount of time in milliseconds before revalidation of a stale 183 | * cache entry is started 184 | * 185 | * Must be positive and finite 186 | * 187 | * Default: `0` 188 | * @deprecated manually delay background refreshes in getFreshValue instead 189 | * @see https://github.com/epicweb-dev/cachified/issues/132 190 | */ 191 | staleRefreshTimeout?: number; 192 | /** 193 | * @deprecated pass reporter as second argument to cachified 194 | */ 195 | reporter?: never; 196 | /** 197 | * Promises passed to `waitUntil` represent background tasks which must be 198 | * completed before the server can shutdown. e.g. swr cache revalidation 199 | * 200 | * Useful for serverless environments such as Cloudflare Workers. 201 | * 202 | * Default: `undefined` 203 | */ 204 | waitUntil?: (promise: Promise) => void; 205 | /** 206 | * Trace ID for debugging, is stored along cache metadata and can be accessed 207 | * in `getFreshValue` and reporter 208 | */ 209 | traceId?: any; 210 | } 211 | 212 | /* When using a schema validator, a strongly typed getFreshValue is not required 213 | and sometimes even sub-optimal */ 214 | export type CachifiedOptionsWithSchema = Omit< 215 | CachifiedOptions, 216 | 'checkValue' | 'getFreshValue' 217 | > & { 218 | checkValue: StandardSchemaV1 | Schema; 219 | getFreshValue: GetFreshValue; 220 | }; 221 | 222 | export interface Context 223 | extends Omit< 224 | Required>, 225 | 'fallbackToCache' | 'reporter' | 'checkValue' | 'swr' | 'traceId' 226 | > { 227 | checkValue: CheckValue; 228 | report: Reporter; 229 | fallbackToCache: number; 230 | metadata: CacheMetadata; 231 | traceId?: any; 232 | } 233 | 234 | function validateWithSchema( 235 | checkValue: StandardSchemaV1 | Schema, 236 | ): CheckValue { 237 | return async (value, migrate) => { 238 | let validatedValue; 239 | 240 | /* Standard Schema validation 241 | https://github.com/standard-schema/standard-schema?tab=readme-ov-file#how-do-i-accept-standard-schemas-in-my-library */ 242 | if ('~standard' in checkValue) { 243 | let result = checkValue['~standard'].validate(value); 244 | if (result instanceof Promise) result = await result; 245 | 246 | if (result.issues) { 247 | throw result.issues; 248 | } 249 | 250 | validatedValue = result.value; 251 | } else { 252 | /* Legacy Schema validation for zod only 253 | TODO: remove in next major version */ 254 | validatedValue = await checkValue.parseAsync(value); 255 | } 256 | 257 | return migrate(validatedValue, false); 258 | }; 259 | } 260 | 261 | export function createContext( 262 | { fallbackToCache, checkValue, ...options }: CachifiedOptions, 263 | reporter?: CreateReporter, 264 | ): Context { 265 | const ttl = options.ttl ?? Infinity; 266 | const staleWhileRevalidate = options.swr ?? options.staleWhileRevalidate ?? 0; 267 | const checkValueCompat: CheckValue = 268 | typeof checkValue === 'function' 269 | ? checkValue 270 | : typeof checkValue === 'object' 271 | ? validateWithSchema(checkValue) 272 | : () => true; 273 | 274 | const contextWithoutReport = { 275 | checkValue: checkValueCompat, 276 | ttl, 277 | staleWhileRevalidate, 278 | fallbackToCache: 279 | fallbackToCache === false 280 | ? 0 281 | : fallbackToCache === true || fallbackToCache === undefined 282 | ? Infinity 283 | : fallbackToCache, 284 | staleRefreshTimeout: 0, 285 | forceFresh: false, 286 | ...options, 287 | metadata: createCacheMetaData({ 288 | ttl, 289 | swr: staleWhileRevalidate, 290 | traceId: options.traceId, 291 | }), 292 | waitUntil: options.waitUntil ?? (() => {}), 293 | }; 294 | 295 | const report = 296 | reporter?.(contextWithoutReport) || 297 | (() => { 298 | /* ¯\_(ツ)_/¯ */ 299 | }); 300 | 301 | return { 302 | ...contextWithoutReport, 303 | report, 304 | }; 305 | } 306 | 307 | export function staleWhileRevalidate(metadata: CacheMetadata): number | null { 308 | return ( 309 | (typeof metadata.swr === 'undefined' ? metadata.swv : metadata.swr) || null 310 | ); 311 | } 312 | 313 | export function totalTtl(metadata?: CacheMetadata): number { 314 | if (!metadata) { 315 | return 0; 316 | } 317 | if (metadata.ttl === null) { 318 | return Infinity; 319 | } 320 | return (metadata.ttl || 0) + (staleWhileRevalidate(metadata) || 0); 321 | } 322 | 323 | export function createCacheMetaData({ 324 | ttl = null, 325 | swr = 0, 326 | createdTime = Date.now(), 327 | traceId, 328 | }: Partial> = {}) { 329 | return { 330 | ttl: ttl === Infinity ? null : ttl, 331 | swr: swr === Infinity ? null : swr, 332 | createdTime, 333 | ...(traceId ? { traceId } : {}), 334 | }; 335 | } 336 | 337 | export function createCacheEntry( 338 | value: Value, 339 | metadata?: Partial>, 340 | ): CacheEntry { 341 | return { 342 | value, 343 | metadata: createCacheMetaData(metadata), 344 | }; 345 | } 346 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

🤑 @epic-web/cachified

3 | 4 | A simple API to make your app faster. 5 | 6 |

7 | Cachified allows you to cache values with support for time-to-live (ttl), 8 | stale-while-revalidate (swr), cache value validation, batching, and 9 | type-safety. 10 |

11 |
12 | 13 | ``` 14 | npm install @epic-web/cachified 15 | ``` 16 | 17 |
18 | 22 | 26 | 27 |
28 | 29 |
30 | 31 | 32 | [![Build Status][build-badge]][build] 33 | [![MIT License][license-badge]][license] 34 | [![Code of Conduct][coc-badge]][coc] 35 | 36 | 37 | Watch the talk ["Caching for Cash 🤑"](https://www.epicweb.dev/talks/caching-for-cash) 38 | on [EpicWeb.dev](https://www.epicweb.dev): 39 | 40 | [![Kent smiling with the cachified README on npm behind him](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/286321796-a280783c-9c99-46fe-abbb-85ac3dc4fd43.png)](https://www.epicweb.dev/talks/caching-for-cash) 41 | 42 | ## Install 43 | 44 | ```sh 45 | npm install @epic-web/cachified 46 | # yarn add @epic-web/cachified 47 | ``` 48 | 49 | ## Usage 50 | 51 | 52 | 53 | ```ts 54 | import { LRUCache } from 'lru-cache'; 55 | import { cachified, CacheEntry, Cache, totalTtl } from '@epic-web/cachified'; 56 | 57 | /* lru cache is not part of this package but a simple non-persistent cache */ 58 | const lruInstance = new LRUCache({ max: 1000 }); 59 | 60 | const lru: Cache = { 61 | set(key, value) { 62 | const ttl = totalTtl(value?.metadata); 63 | return lruInstance.set(key, value, { 64 | ttl: ttl === Infinity ? undefined : ttl, 65 | start: value?.metadata?.createdTime, 66 | }); 67 | }, 68 | get(key) { 69 | return lruInstance.get(key); 70 | }, 71 | delete(key) { 72 | return lruInstance.delete(key); 73 | }, 74 | }; 75 | 76 | function getUserById(userId: number) { 77 | return cachified({ 78 | key: `user-${userId}`, 79 | cache: lru, 80 | async getFreshValue() { 81 | /* Normally we want to either use a type-safe API or `checkValue` but 82 | to keep this example simple we work with `any` */ 83 | const response = await fetch( 84 | `https://jsonplaceholder.typicode.com/users/${userId}`, 85 | ); 86 | return response.json(); 87 | }, 88 | /* 5 minutes until cache gets invalid 89 | * Optional, defaults to Infinity */ 90 | ttl: 300_000, 91 | }); 92 | } 93 | 94 | // Let's get through some calls of `getUserById`: 95 | 96 | console.log(await getUserById(1)); 97 | // > logs the user with ID 1 98 | // Cache was empty, `getFreshValue` got invoked and fetched the user-data that 99 | // is now cached for 5 minutes 100 | 101 | // 2 minutes later 102 | console.log(await getUserById(1)); 103 | // > logs the exact same user-data 104 | // Cache was filled an valid. `getFreshValue` was not invoked 105 | 106 | // 10 minutes later 107 | console.log(await getUserById(1)); 108 | // > logs the user with ID 1 that might have updated fields 109 | // Cache timed out, `getFreshValue` got invoked to fetch a fresh copy of the user 110 | // that now replaces current cache entry and is cached for 5 minutes 111 | ``` 112 | 113 | ## Options 114 | 115 | 116 | 117 | ```ts 118 | interface CachifiedOptions { 119 | /** 120 | * Required 121 | * 122 | * The key this value is cached by 123 | * Must be unique for each value 124 | */ 125 | key: string; 126 | /** 127 | * Required 128 | * 129 | * Cache implementation to use 130 | * 131 | * Must conform with signature 132 | * - set(key: string, value: object): void | Promise 133 | * - get(key: string): object | Promise 134 | * - delete(key: string): void | Promise 135 | */ 136 | cache: Cache; 137 | /** 138 | * Required 139 | * 140 | * Function that is called when no valid value is in cache for given key 141 | * Basically what we would do if we wouldn't use a cache 142 | * 143 | * Can be async and must return fresh value or throw 144 | * 145 | * receives context object as argument 146 | * - context.metadata.ttl?: number 147 | * - context.metadata.swr?: number 148 | * - context.metadata.createdTime: number 149 | * - context.background: boolean 150 | */ 151 | getFreshValue: GetFreshValue; 152 | /** 153 | * Time To Live; often also referred to as max age 154 | * 155 | * Amount of milliseconds the value should stay in cache 156 | * before we get a fresh one 157 | * 158 | * Setting any negative value will disable caching 159 | * Can be infinite 160 | * 161 | * Default: `Infinity` 162 | */ 163 | ttl?: number; 164 | /** 165 | * Amount of milliseconds that a value with exceeded ttl is still returned 166 | * while a fresh value is refreshed in the background 167 | * 168 | * Should be positive, can be infinite 169 | * 170 | * Default: `0` 171 | */ 172 | staleWhileRevalidate?: number; 173 | /** 174 | * Alias for staleWhileRevalidate 175 | */ 176 | swr?: number; 177 | /** 178 | * Validator that checks every cached and fresh value to ensure type safety 179 | * 180 | * Can be a standard schema validator or a custom validator function 181 | * @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec 182 | * 183 | * Value considered ok when: 184 | * - schema succeeds 185 | * - validator returns 186 | * - true 187 | * - migrate(newValue) 188 | * - undefined 189 | * - null 190 | * 191 | * Value considered bad when: 192 | * - schema throws 193 | * - validator: 194 | * - returns false 195 | * - returns reason as string 196 | * - throws 197 | * 198 | * A validator function receives two arguments: 199 | * 1. the value 200 | * 2. a migrate callback, see https://github.com/epicweb-dev/cachified#migrating-values 201 | * 202 | * Default: `undefined` - no validation 203 | */ 204 | checkValue?: 205 | | CheckValue 206 | | StandardSchemaV1 207 | | Schema; 208 | /** 209 | * Set true to not even try reading the currently cached value 210 | * 211 | * Will write new value to cache even when cached value is 212 | * still valid. 213 | * 214 | * Default: `false` 215 | */ 216 | forceFresh?: boolean; 217 | /** 218 | * Whether or not to fall back to cache when getting a forced fresh value 219 | * fails 220 | * 221 | * Can also be a positive number as the maximum age in milliseconds that a 222 | * fallback value might have 223 | * 224 | * Default: `Infinity` 225 | */ 226 | fallbackToCache?: boolean | number; 227 | /** 228 | * Promises passed to `waitUntil` represent background tasks which must be 229 | * completed before the server can shutdown. e.g. swr cache revalidation 230 | * 231 | * Useful for serverless environments such as Cloudflare Workers. 232 | * 233 | * Default: `undefined` 234 | */ 235 | waitUntil?: (promise: Promise) => void; 236 | /** 237 | * Trace ID for debugging, is stored along cache metadata and can be accessed 238 | * in `getFreshValue` and reporter 239 | */ 240 | traceId?: any; 241 | } 242 | ``` 243 | 244 | ## Adapters 245 | 246 | There are some adapters available for common caches. Using them makes sure the used caches cleanup outdated values themselves. 247 | 248 | - Adapter for [redis](https://www.npmjs.com/package/redis) : [cachified-redis-adapter](https://www.npmjs.com/package/cachified-redis-adapter) 249 | - Adapter for [redis-json](https://www.npmjs.com/package/@redis/json) : [cachified-redis-json-adapter](https://www.npmjs.com/package/cachified-redis-json-adapter) 250 | - Adapter for [Cloudflare KV](https://developers.cloudflare.com/kv/) : [cachified-adapter-cloudflare-kv repository](https://github.com/AdiRishi/cachified-adapter-cloudflare-kv) 251 | - Adapter for SQLite : [cachified-adapter-sqlite](https://npmjs.com/package/cachified-adapter-sqlite) 252 | 253 | ## Advanced Usage 254 | 255 | ### Stale while revalidate 256 | 257 | Specify a time window in which a cached value is returned even though 258 | it's ttl is exceeded while the cache is updated in the background for the next 259 | call. 260 | 261 | 262 | 263 | ```ts 264 | import { cachified } from '@epic-web/cachified'; 265 | 266 | const cache = new Map(); 267 | 268 | function getUserById(userId: number) { 269 | return cachified({ 270 | ttl: 120_000 /* Two minutes */, 271 | staleWhileRevalidate: 300_000 /* Five minutes */, 272 | 273 | cache, 274 | key: `user-${userId}`, 275 | async getFreshValue() { 276 | const response = await fetch( 277 | `https://jsonplaceholder.typicode.com/users/${userId}`, 278 | ); 279 | return response.json(); 280 | }, 281 | }); 282 | } 283 | 284 | console.log(await getUserById(1)); 285 | // > logs the user with ID 1 286 | // Cache is empty, `getFreshValue` gets invoked and and its value returned and 287 | // cached for 7 minutes total. After 2 minutes the cache will start refreshing in background 288 | 289 | // 30 seconds later 290 | console.log(await getUserById(1)); 291 | // > logs the exact same user-data 292 | // Cache is filled an valid. `getFreshValue` is not invoked, cached value is returned 293 | 294 | // 4 minutes later 295 | console.log(await getUserById(1)); 296 | // > logs the exact same user-data 297 | // Cache timed out but stale while revalidate is not exceeded. 298 | // cached value is returned immediately, `getFreshValue` gets invoked in the 299 | // background and its value is cached for the next 7 minutes 300 | 301 | // 30 seconds later 302 | console.log(await getUserById(1)); 303 | // > logs fresh user-data from the previous call 304 | // Cache is filled an valid. `getFreshValue` is not invoked, cached value is returned 305 | ``` 306 | 307 | ### Forcing fresh values and falling back to cache 308 | 309 | We can use `forceFresh` to get a fresh value regardless of the values ttl or stale while validate 310 | 311 | 312 | 313 | ```ts 314 | import { cachified } from '@epic-web/cachified'; 315 | 316 | const cache = new Map(); 317 | 318 | function getUserById(userId: number, forceFresh?: boolean) { 319 | return cachified({ 320 | forceFresh, 321 | /* when getting a forced fresh value fails we fall back to cached value 322 | as long as it's not older then 5 minutes */ 323 | fallbackToCache: 300_000 /* 5 minutes, defaults to Infinity */, 324 | 325 | cache, 326 | key: `user-${userId}`, 327 | async getFreshValue() { 328 | const response = await fetch( 329 | `https://jsonplaceholder.typicode.com/users/${userId}`, 330 | ); 331 | return response.json(); 332 | }, 333 | }); 334 | } 335 | 336 | console.log(await getUserById(1)); 337 | // > logs the user with ID 1 338 | // Cache is empty, `getFreshValue` gets invoked and and its value returned 339 | 340 | console.log(await getUserById(1, true)); 341 | // > logs fresh user with ID 1 342 | // Cache is filled an valid. but we forced a fresh value, so `getFreshValue` is invoked 343 | ``` 344 | 345 | ### Type-safety 346 | 347 | In practice we can not be entirely sure that values from cache are of the types we assume. 348 | For example other parties could also write to the cache or code is changed while cache 349 | stays the same. 350 | 351 | 352 | 353 | ```ts 354 | import { cachified, createCacheEntry } from '@epic-web/cachified'; 355 | 356 | const cache = new Map(); 357 | 358 | /* Assume something bad happened and we have an invalid cache entry... */ 359 | cache.set('user-1', createCacheEntry('INVALID') as any); 360 | 361 | function getUserById(userId: number) { 362 | return cachified({ 363 | checkValue(value: unknown) { 364 | if (!isRecord(value)) { 365 | /* We can either throw to indicate a bad value */ 366 | throw new Error(`Expected user to be object, got ${typeof value}`); 367 | } 368 | 369 | if (typeof value.email !== 'string') { 370 | /* Or return a reason/message string */ 371 | return `Expected user-${userId} to have an email`; 372 | } 373 | 374 | if (typeof value.username !== 'string') { 375 | /* Or just say no... */ 376 | return false; 377 | } 378 | 379 | /* undefined, true or null are considered OK */ 380 | }, 381 | 382 | cache, 383 | key: `user-${userId}`, 384 | async getFreshValue() { 385 | const response = await fetch( 386 | `https://jsonplaceholder.typicode.com/users/${userId}`, 387 | ); 388 | return response.json(); 389 | }, 390 | }); 391 | } 392 | 393 | function isRecord(value: unknown): value is Record { 394 | return typeof value === 'object' && value !== null && !Array.isArray(value); 395 | } 396 | 397 | console.log(await getUserById(1)); 398 | // > logs the user with ID 1 399 | // Cache was not empty but value was invalid, `getFreshValue` got invoked and 400 | // and the cache was updated 401 | 402 | console.log(await getUserById(1)); 403 | // > logs the exact same data as above 404 | // Cache was filled an valid. `getFreshValue` was not invoked 405 | ``` 406 | 407 | > ℹ️ `checkValue` is also invoked with the return value of `getFreshValue` 408 | 409 | ### Type-safety with [schema libraries](https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec) 410 | 411 | We can also use zod, valibot or other libraries implementing the standard schema spec to ensure correct types 412 | 413 | 414 | 415 | ```ts 416 | import { cachified, createCacheEntry } from '@epic-web/cachified'; 417 | import z from 'zod'; 418 | 419 | const cache = new Map(); 420 | /* Assume something bad happened and we have an invalid cache entry... */ 421 | cache.set('user-1', createCacheEntry('INVALID') as any); 422 | 423 | function getUserById(userId: number) { 424 | return cachified({ 425 | checkValue: z.object({ 426 | email: z.string(), 427 | }), 428 | 429 | cache, 430 | key: `user-${userId}`, 431 | async getFreshValue() { 432 | const response = await fetch( 433 | `https://jsonplaceholder.typicode.com/users/${userId}`, 434 | ); 435 | return response.json(); 436 | }, 437 | }); 438 | } 439 | 440 | console.log(await getUserById(1)); 441 | // > logs the user with ID 1 442 | // Cache was not empty but value was invalid, `getFreshValue` got invoked and 443 | // and the cache was updated 444 | 445 | console.log(await getUserById(1)); 446 | // > logs the exact same data as above 447 | // Cache was filled an valid. `getFreshValue` was not invoked 448 | ``` 449 | 450 | ### Pre-configuring cachified 451 | 452 | We can create versions of cachified with defaults so that we don't have to 453 | specify the same options every time. 454 | 455 | 456 | 457 | ```ts 458 | import { configure } from '@epic-web/cachified'; 459 | import { LRUCache } from 'lru-cache'; 460 | 461 | /* lruCachified now has a default cache */ 462 | const lruCachified = configure({ 463 | cache: new LRUCache({ max: 1000 }), 464 | }); 465 | 466 | const value = await lruCachified({ 467 | key: 'user-1', 468 | getFreshValue: async () => 'ONE', 469 | }); 470 | ``` 471 | 472 | ### Manually working with the cache 473 | 474 | During normal app lifecycle there usually is no need for this but for 475 | maintenance and testing these helpers might come handy. 476 | 477 | 478 | 479 | ```ts 480 | import { 481 | createCacheEntry, 482 | assertCacheEntry, 483 | isExpired, 484 | cachified, 485 | } from '@epic-web/cachified'; 486 | 487 | const cache = new Map(); 488 | 489 | /* Manually set an entry to cache */ 490 | cache.set( 491 | 'user-1', 492 | createCacheEntry( 493 | 'someone@example.org', 494 | /* Optional CacheMetadata */ 495 | { ttl: 300_000, swr: Infinity }, 496 | ), 497 | ); 498 | 499 | /* Receive the value with cachified */ 500 | const value: string = await cachified({ 501 | cache, 502 | key: 'user-1', 503 | getFreshValue() { 504 | throw new Error('This is not called since cache is set earlier'); 505 | }, 506 | }); 507 | console.log(value); 508 | // > logs "someone@example.org" 509 | 510 | /* Manually get a value from cache */ 511 | const entry: unknown = cache.get('user-1'); 512 | assertCacheEntry(entry); // will throw when entry is not a valid CacheEntry 513 | console.log(entry.value); 514 | // > logs "someone@example.org" 515 | 516 | /* Manually check if an entry is expired */ 517 | const expired = isExpired(entry.metadata); 518 | console.log(expired); 519 | // > logs true, "stale" or false 520 | 521 | /* Manually remove an entry from cache */ 522 | cache.delete('user-1'); 523 | ``` 524 | 525 | ### Migrating Values 526 | 527 | When the format of cached values is changed during the apps lifetime they can 528 | be migrated on read like this: 529 | 530 | 531 | 532 | ```ts 533 | import { cachified, createCacheEntry } from '@epic-web/cachified'; 534 | 535 | const cache = new Map(); 536 | 537 | /* Let's assume we've previously only stored emails not user objects */ 538 | cache.set('user-1', createCacheEntry('someone@example.org')); 539 | 540 | function getUserById(userId: number) { 541 | return cachified({ 542 | checkValue(value, migrate) { 543 | if (typeof value === 'string') { 544 | return migrate({ email: value }); 545 | } 546 | /* other validations... */ 547 | }, 548 | 549 | key: 'user-1', 550 | cache, 551 | getFreshValue() { 552 | throw new Error('This is never called'); 553 | }, 554 | }); 555 | } 556 | 557 | console.log(await getUserById(1)); 558 | // > logs { email: 'someone@example.org' } 559 | // Cache is filled and invalid but value can be migrated from email to user-object 560 | // `getFreshValue` is not invoked 561 | 562 | console.log(await getUserById(1)); 563 | // > logs the exact same data as above 564 | // Cache is filled an valid. 565 | ``` 566 | 567 | ### Soft-purging entries 568 | 569 | Soft-purging cached data has the benefit of not immediately putting pressure on the app 570 | to update all cached values at once and instead allows to get them updated over time. 571 | 572 | More details: [Soft vs. hard purge](https://developer.fastly.com/reference/api/purging/#soft-vs-hard-purge) 573 | 574 | 575 | 576 | ```ts 577 | import { cachified, softPurge } from '@epic-web/cachified'; 578 | 579 | const cache = new Map(); 580 | 581 | function getUserById(userId: number) { 582 | return cachified({ 583 | cache, 584 | key: `user-${userId}`, 585 | ttl: 300_000, 586 | async getFreshValue() { 587 | const response = await fetch( 588 | `https://jsonplaceholder.typicode.com/users/${userId}`, 589 | ); 590 | return response.json(); 591 | }, 592 | }); 593 | } 594 | 595 | console.log(await getUserById(1)); 596 | // > logs user with ID 1 597 | // cache was empty, fresh value was requested and is cached for 5 minutes 598 | 599 | await softPurge({ 600 | cache, 601 | key: 'user-1', 602 | }); 603 | // This internally sets the ttl to 0 and staleWhileRevalidate to 300_000 604 | 605 | // 10 seconds later 606 | console.log(await getUserById(1)); 607 | // > logs the outdated, soft-purged data 608 | // cache has been soft-purged, the cached value got returned and a fresh value 609 | // is requested in the background and again cached for 5 minutes 610 | 611 | // 1 minute later 612 | console.log(await getUserById(1)); 613 | // > logs the fresh data that got refreshed by the previous call 614 | 615 | await softPurge({ 616 | cache, 617 | key: 'user-1', 618 | // manually overwrite how long the stale data should stay in cache 619 | staleWhileRevalidate: 60_000 /* one minute from now on */, 620 | }); 621 | 622 | // 2 minutes later 623 | console.log(await getUserById(1)); 624 | // > logs completely fresh data 625 | ``` 626 | 627 | > ℹ️ In case we need to fully purge the value, we delete the key directly from our cache 628 | 629 | ### Fine-tuning cache metadata based on fresh values 630 | 631 | There are scenarios where we want to change the cache time based on the fresh 632 | value (ref [#25](https://github.com/epicweb-dev/cachified/issues/25)). 633 | For example when an API might either provide our data or `null` and in case we 634 | get an empty result we want to retry the API much faster. 635 | 636 | 637 | 638 | ```ts 639 | import { cachified } from '@epic-web/cachified'; 640 | 641 | const cache = new Map(); 642 | 643 | const value: null | string = await cachified({ 644 | ttl: 60_000 /* Default cache of one minute... */, 645 | async getFreshValue(context) { 646 | const response = await fetch( 647 | `https://jsonplaceholder.typicode.com/users/1`, 648 | ); 649 | const data = await response.json(); 650 | 651 | if (data === null) { 652 | /* On an empty result, prevent caching */ 653 | context.metadata.ttl = -1; 654 | } 655 | 656 | return data; 657 | }, 658 | 659 | cache, 660 | key: 'user-1', 661 | }); 662 | ``` 663 | 664 | ### Batch requesting values 665 | 666 | In case multiple values can be requested in a batch action, but it's not 667 | clear which values are currently in cache we can use the `createBatch` helper 668 | 669 | 670 | 671 | ```ts 672 | import { cachified, createBatch } from '@epic-web/cachified'; 673 | 674 | const cache = new Map(); 675 | 676 | async function getFreshValues(idsThatAreNotInCache: number[]) { 677 | const res = await fetch( 678 | `https://example.org/api?ids=${idsThatAreNotInCache.join(',')}`, 679 | ); 680 | const data = await res.json(); 681 | 682 | // Validate data here... 683 | 684 | return data; 685 | } 686 | 687 | function getUsersWithId(ids: number[]) { 688 | const batch = createBatch(getFreshValues); 689 | 690 | return Promise.all( 691 | ids.map((id) => 692 | cachified({ 693 | getFreshValue: batch.add( 694 | id, 695 | /* onValue callback is optional but can be used to manipulate 696 | * cache metadata based on the received value. (see section above) */ 697 | ({ value, ...context }) => {}, 698 | ), 699 | 700 | cache, 701 | key: `entry-${id}`, 702 | ttl: 60_000, 703 | }), 704 | ), 705 | ); 706 | } 707 | 708 | console.log(await getUsersWithId([1, 2])); 709 | // > logs user objects for ID 1 & ID 2 710 | // Caches is completely empty. `getFreshValues` is invoked with `[1, 2]` 711 | // and its return values cached separately 712 | 713 | // 1 minute later 714 | console.log(await getUsersWithId([2, 3])); 715 | // > logs user objects for ID 2 & ID 3 716 | // User with ID 2 is in cache, `getFreshValues` is invoked with `[3]` 717 | // cachified returns with one value from cache and one fresh value 718 | ``` 719 | 720 | ### Reporting 721 | 722 | A reporter might be passed as second argument to cachified to log caching events, we ship a reporter 723 | resembling the logging from [Kents implementation](https://github.com/kentcdodds/kentcdodds.com/blob/3efd0d3a07974ece0ee64d665f5e2159a97585df/app/utils/cache.server.ts) 724 | 725 | 726 | 727 | ```ts 728 | import { cachified, verboseReporter } from '@epic-web/cachified'; 729 | 730 | const cache = new Map(); 731 | 732 | await cachified( 733 | { 734 | cache, 735 | key: 'user-1', 736 | async getFreshValue() { 737 | const response = await fetch( 738 | `https://jsonplaceholder.typicode.com/users/1`, 739 | ); 740 | return response.json(); 741 | }, 742 | }, 743 | verboseReporter(), 744 | ); 745 | ``` 746 | 747 | please refer to [the implementation of `verboseReporter`](https://github.com/epicweb-dev/cachified/blob/main/src/reporter.ts#L125) when you want to implement a custom reporter. 748 | 749 | ## License 750 | 751 | MIT 752 | 753 | 754 | [build-badge]: https://img.shields.io/github/actions/workflow/status/epicweb-dev/cachified/release.yml?branch=main&logo=github&style=flat-square 755 | [build]: https://github.com/epicweb-dev/cachified/actions?query=workflow%3Arelease 756 | [license-badge]: https://img.shields.io/badge/license-MIT%20License-blue.svg?style=flat-square 757 | [license]: https://github.com/epicweb-dev/cachified/blob/main/LICENSE 758 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 759 | [coc]: https://kentcdodds.com/conduct 760 | 761 | -------------------------------------------------------------------------------- /src/cachified.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | TODO(next-major): remove zod-legacy in favor of zod@>3.24 3 | and update tests to only use standard schema 4 | */ 5 | import z from 'zod-legacy'; 6 | import { 7 | cachified, 8 | CachifiedOptions, 9 | Context, 10 | createBatch, 11 | CreateReporter, 12 | CacheMetadata, 13 | CacheEntry, 14 | GetFreshValue, 15 | createCacheEntry, 16 | getPendingValuesCache, 17 | } from './index'; 18 | import { Deferred } from './createBatch'; 19 | import { delay, report } from './testHelpers'; 20 | import { StandardSchemaV1 } from './StandardSchemaV1'; 21 | import { configure } from './configure'; 22 | 23 | jest.mock('./index', () => { 24 | if (process.version.startsWith('v20')) { 25 | return jest.requireActual('./index'); 26 | } else { 27 | console.log('⚠️ Running Tests against dist/index.cjs'); 28 | return require('../dist/index.cjs'); 29 | } 30 | }); 31 | 32 | function ignoreNode14(callback: () => T) { 33 | if (process.version.startsWith('v14')) { 34 | return; 35 | } 36 | return callback(); 37 | } 38 | 39 | const anyMetadata = expect.objectContaining({ 40 | createdTime: expect.any(Number), 41 | } satisfies CacheMetadata); 42 | 43 | let currentTime = 0; 44 | beforeEach(() => { 45 | currentTime = 0; 46 | jest.spyOn(Date, 'now').mockImplementation(() => currentTime); 47 | }); 48 | 49 | describe('cachified', () => { 50 | it('caches a value', async () => { 51 | const cache = new Map(); 52 | const reporter = createReporter(); 53 | const reporter2 = createReporter(); 54 | 55 | const value = await cachified( 56 | { 57 | cache, 58 | key: 'test', 59 | getFreshValue() { 60 | return 'ONE'; 61 | }, 62 | }, 63 | reporter, 64 | ); 65 | 66 | const value2 = await cachified( 67 | { 68 | cache, 69 | key: 'test', 70 | getFreshValue() { 71 | throw new Error('🚧'); 72 | }, 73 | }, 74 | reporter2, 75 | ); 76 | 77 | expect(value).toBe('ONE'); 78 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 79 | "1. init 80 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 81 | 2. getCachedValueStart 82 | 3. getCachedValueRead 83 | 4. getCachedValueEmpty 84 | 5. getFreshValueStart 85 | 6. getFreshValueSuccess 86 | {value: 'ONE'} 87 | 7. writeFreshValueSuccess 88 | {metadata: {createdTime: 0, swr: 0, ttl: null}, migrated: false, written: true} 89 | 8. done 90 | {value: 'ONE'}" 91 | `); 92 | 93 | expect(value2).toBe('ONE'); 94 | expect(report(reporter2.mock.calls)).toMatchInlineSnapshot(` 95 | "1. init 96 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 97 | 2. getCachedValueStart 98 | 3. getCachedValueRead 99 | {entry: {metadata: {createdTime: 0, swr: 0, ttl: null}, value: 'ONE'}} 100 | 4. getCachedValueSuccess 101 | {migrated: false, value: 'ONE'} 102 | 5. done 103 | {value: 'ONE'}" 104 | `); 105 | }); 106 | 107 | it('does not cache a value when ttl is negative', async () => { 108 | const cache = new Map(); 109 | 110 | const value = await cachified({ 111 | cache, 112 | key: 'test', 113 | ttl: -1, 114 | getFreshValue() { 115 | return 'ONE'; 116 | }, 117 | }); 118 | 119 | expect(value).toBe('ONE'); 120 | expect(cache.size).toBe(0); 121 | }); 122 | 123 | it('immediately refreshes when ttl is 0', async () => { 124 | const cache = new Map(); 125 | 126 | const value = await cachified({ 127 | cache, 128 | key: 'test', 129 | ttl: 0, 130 | getFreshValue() { 131 | return 'ONE'; 132 | }, 133 | }); 134 | 135 | currentTime = 1; 136 | const value2 = await cachified({ 137 | cache, 138 | key: 'test', 139 | ttl: 0, 140 | getFreshValue() { 141 | return 'TWO'; 142 | }, 143 | }); 144 | 145 | expect(value).toBe('ONE'); 146 | expect(value2).toBe('TWO'); 147 | }); 148 | 149 | it('caches undefined values', async () => { 150 | const cache = new Map(); 151 | 152 | const value = await cachified({ 153 | cache, 154 | key: 'test', 155 | getFreshValue() { 156 | return undefined; 157 | }, 158 | }); 159 | 160 | const value2 = await cachified({ 161 | cache, 162 | key: 'test', 163 | getFreshValue() { 164 | throw new Error('🛸'); 165 | }, 166 | }); 167 | 168 | expect(value).toBe(undefined); 169 | expect(value2).toBe(undefined); 170 | }); 171 | 172 | it('caches null values', async () => { 173 | const cache = new Map(); 174 | 175 | const value = await cachified({ 176 | cache, 177 | key: 'test', 178 | getFreshValue() { 179 | return null; 180 | }, 181 | }); 182 | 183 | const value2 = await cachified({ 184 | cache, 185 | key: 'test', 186 | getFreshValue() { 187 | throw new Error('🛸'); 188 | }, 189 | }); 190 | 191 | expect(value).toBe(null); 192 | expect(value2).toBe(null); 193 | }); 194 | 195 | it('throws when no fresh value can be received for empty cache', async () => { 196 | const cache = new Map(); 197 | const reporter = createReporter(); 198 | 199 | const value = cachified( 200 | { 201 | cache, 202 | key: 'test', 203 | getFreshValue() { 204 | throw new Error('🙈'); 205 | }, 206 | }, 207 | reporter, 208 | ); 209 | 210 | await expect(value).rejects.toMatchInlineSnapshot(`[Error: 🙈]`); 211 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 212 | "1. init 213 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 214 | 2. getCachedValueStart 215 | 3. getCachedValueRead 216 | 4. getCachedValueEmpty 217 | 5. getFreshValueStart 218 | 6. getFreshValueError 219 | {error: [Error: 🙈]}" 220 | `); 221 | }); 222 | 223 | it('throws when no forced fresh value can be received on empty cache', async () => { 224 | const cache = new Map(); 225 | 226 | const value = cachified({ 227 | cache, 228 | key: 'test', 229 | forceFresh: true, 230 | getFreshValue() { 231 | throw new Error('☠️'); 232 | }, 233 | }); 234 | 235 | await expect(value).rejects.toMatchInlineSnapshot(`[Error: ☠️]`); 236 | }); 237 | 238 | it('throws when fresh value does not meet value check', async () => { 239 | const cache = new Map(); 240 | const reporter = createReporter(); 241 | const reporter2 = createReporter(); 242 | 243 | const value = cachified( 244 | { 245 | cache, 246 | key: 'test', 247 | checkValue() { 248 | return '👮'; 249 | }, 250 | getFreshValue() { 251 | return 'ONE'; 252 | }, 253 | }, 254 | reporter, 255 | ); 256 | 257 | await expect(value).rejects.toThrowErrorMatchingInlineSnapshot(` 258 | "check failed for fresh value of test 259 | Cause: 👮" 260 | `); 261 | 262 | await ignoreNode14(() => 263 | expect(value.catch((err) => err.cause)).resolves.toMatchInlineSnapshot( 264 | `"👮"`, 265 | ), 266 | ); 267 | 268 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 269 | "1. init 270 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 271 | 2. getCachedValueStart 272 | 3. getCachedValueRead 273 | 4. getCachedValueEmpty 274 | 5. getFreshValueStart 275 | 6. getFreshValueSuccess 276 | {value: 'ONE'} 277 | 7. checkFreshValueErrorObj 278 | {reason: '👮'} 279 | 8. checkFreshValueError 280 | {reason: '👮'}" 281 | `); 282 | 283 | // The following lines only exist to have 100% coverage 😅 284 | const value2 = cachified( 285 | { 286 | cache, 287 | key: 'test', 288 | checkValue() { 289 | return false; 290 | }, 291 | getFreshValue() { 292 | return 'ONE'; 293 | }, 294 | }, 295 | reporter2, 296 | ); 297 | await expect(value2).rejects.toThrowErrorMatchingInlineSnapshot(` 298 | "check failed for fresh value of test 299 | Cause: unknown" 300 | `); 301 | expect(report(reporter2.mock.calls)).toMatchInlineSnapshot(` 302 | "1. init 303 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 304 | 2. getCachedValueStart 305 | 3. getCachedValueRead 306 | 4. getCachedValueEmpty 307 | 5. getFreshValueStart 308 | 6. getFreshValueSuccess 309 | {value: 'ONE'} 310 | 7. checkFreshValueErrorObj 311 | {reason: 'unknown'} 312 | 8. checkFreshValueError 313 | {reason: 'unknown'}" 314 | `); 315 | }); 316 | 317 | it('supports zod validation with checkValue', async () => { 318 | const cache = new Map(); 319 | 320 | const value = await cachified({ 321 | cache, 322 | key: 'test', 323 | checkValue: z.string(), 324 | getFreshValue() { 325 | return 'ONE'; 326 | }, 327 | }); 328 | 329 | expect(value).toBe('ONE'); 330 | }); 331 | 332 | it('fails when zod-schema does not match fresh value', async () => { 333 | const cache = new Map(); 334 | 335 | const value2 = cachified({ 336 | cache, 337 | key: 'test', 338 | checkValue: z.string(), 339 | /* manually setting unknown here leaves the type-checking to zod during runtime */ 340 | getFreshValue(): unknown { 341 | /* pretend API returns an unexpected value */ 342 | return 1; 343 | }, 344 | }); 345 | 346 | await expect(value2).rejects.toThrowErrorMatchingInlineSnapshot(` 347 | "check failed for fresh value of test 348 | Cause: [ 349 | { 350 | "code": "invalid_type", 351 | "expected": "string", 352 | "received": "number", 353 | "path": [], 354 | "message": "Expected string, received number" 355 | } 356 | ]" 357 | `); 358 | await ignoreNode14(() => 359 | expect(value2.catch((err) => err.cause)).resolves.toMatchInlineSnapshot(` 360 | [ZodError: [ 361 | { 362 | "code": "invalid_type", 363 | "expected": "string", 364 | "received": "number", 365 | "path": [], 366 | "message": "Expected string, received number" 367 | } 368 | ]] 369 | `), 370 | ); 371 | }); 372 | 373 | it('fetches fresh value when zod-schema does not match cached value', async () => { 374 | const cache = new Map(); 375 | 376 | cache.set('test', createCacheEntry(1)); 377 | 378 | const value = await cachified({ 379 | cache, 380 | key: 'test', 381 | checkValue: z.string(), 382 | getFreshValue() { 383 | return 'ONE'; 384 | }, 385 | }); 386 | 387 | expect(value).toBe('ONE'); 388 | }); 389 | 390 | /* I don't think this is a good idea, but it's possible */ 391 | it('supports zod transforms', async () => { 392 | const cache = new Map(); 393 | 394 | const getValue = () => 395 | cachified({ 396 | cache, 397 | key: 'test', 398 | checkValue: z.string().transform((s) => parseInt(s, 10)), 399 | getFreshValue() { 400 | return '123'; 401 | }, 402 | }); 403 | 404 | expect(await getValue()).toBe(123); 405 | 406 | /* Stores original value in cache */ 407 | expect(cache.get('test')?.value).toBe('123'); 408 | 409 | /* Gets transformed value from cache */ 410 | expect(await getValue()).toBe(123); 411 | }); 412 | 413 | it('supports Standard Schema as validators', async () => { 414 | // Implement the schema interface 415 | const checkValue: StandardSchemaV1 = { 416 | '~standard': { 417 | version: 1, 418 | vendor: 'cachified-test', 419 | validate(value) { 420 | return typeof value === 'string' 421 | ? { value: parseInt(value, 10) } 422 | : { issues: [{ message: '🙅' }] }; 423 | }, 424 | }, 425 | }; 426 | 427 | const cache = new Map(); 428 | 429 | const value = await cachified({ 430 | cache, 431 | key: 'test', 432 | checkValue, 433 | getFreshValue() { 434 | return '123'; 435 | }, 436 | }); 437 | 438 | expect(value).toBe(123); 439 | 440 | const invalidValue = cachified({ 441 | cache, 442 | key: 'test-2', 443 | checkValue, 444 | getFreshValue() { 445 | return { invalid: 'value' }; 446 | }, 447 | }); 448 | 449 | await expect(invalidValue).rejects.toThrowErrorMatchingInlineSnapshot( 450 | `"check failed for fresh value of test-2"`, 451 | ); 452 | await ignoreNode14(() => 453 | expect( 454 | invalidValue.catch((err) => err.cause[0].message), 455 | ).resolves.toMatchInlineSnapshot(`"🙅"`), 456 | ); 457 | }); 458 | 459 | it('supports migrating cached values', async () => { 460 | const cache = new Map(); 461 | const reporter = createReporter(); 462 | 463 | cache.set('weather', createCacheEntry('☁️')); 464 | const waitUntil = jest.fn(); 465 | const value = await cachified( 466 | { 467 | cache, 468 | key: 'weather', 469 | checkValue(value, migrate) { 470 | if (value === '☁️') { 471 | return migrate('☀️'); 472 | } 473 | }, 474 | getFreshValue() { 475 | throw new Error('Never'); 476 | }, 477 | waitUntil, 478 | }, 479 | reporter, 480 | ); 481 | 482 | expect(value).toBe('☀️'); 483 | expect(cache.get('weather')?.value).toBe('☁️'); 484 | expect(waitUntil).toHaveBeenCalledTimes(1); 485 | expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise)); 486 | // Wait for promise (migration is done in background) 487 | await waitUntil.mock.calls[0][0]; 488 | expect(cache.get('weather')?.value).toBe('☀️'); 489 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 490 | "1. init 491 | {key: 'weather', metadata: {createdTime: 0, swr: 0, ttl: null}} 492 | 2. getCachedValueStart 493 | 3. getCachedValueRead 494 | {entry: {metadata: {createdTime: 0, swr: 0, ttl: null}, value: '☁️'}} 495 | 4. getCachedValueSuccess 496 | {migrated: true, value: '☀️'} 497 | 5. done 498 | {value: '☀️'}" 499 | `); 500 | }); 501 | 502 | it('supports async value checkers that throw', async () => { 503 | const cache = new Map(); 504 | const reporter = createReporter(); 505 | 506 | const value = cachified( 507 | { 508 | cache, 509 | key: 'weather', 510 | async checkValue(value) { 511 | if (value === '☁️') { 512 | throw new Error('Bad Weather'); 513 | } 514 | }, 515 | getFreshValue() { 516 | return '☁️'; 517 | }, 518 | }, 519 | reporter, 520 | ); 521 | 522 | await expect(value).rejects.toThrowErrorMatchingInlineSnapshot(` 523 | "check failed for fresh value of weather 524 | Cause: Bad Weather" 525 | `); 526 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 527 | "1. init 528 | {key: 'weather', metadata: {createdTime: 0, swr: 0, ttl: null}} 529 | 2. getCachedValueStart 530 | 3. getCachedValueRead 531 | 4. getCachedValueEmpty 532 | 5. getFreshValueStart 533 | 6. getFreshValueSuccess 534 | {value: '☁️'} 535 | 7. checkFreshValueErrorObj 536 | {reason: [Error: Bad Weather]} 537 | 8. checkFreshValueError 538 | {reason: 'Bad Weather'}" 539 | `); 540 | 541 | // Considers anything thrown as an error 542 | 543 | const value2 = cachified( 544 | { 545 | cache, 546 | key: 'weather', 547 | async checkValue(value) { 548 | if (value === '☁️') { 549 | throw { custom: 'idk..' }; 550 | } 551 | }, 552 | getFreshValue() { 553 | return '☁️'; 554 | }, 555 | }, 556 | reporter, 557 | ); 558 | 559 | await expect(value2).rejects.toThrowErrorMatchingInlineSnapshot( 560 | `"check failed for fresh value of weather"`, 561 | ); 562 | }); 563 | 564 | it('does not write migrated value to cache in case a new fresh value is already incoming', async () => { 565 | const cache = new Map(); 566 | const reporter = createReporter(); 567 | 568 | cache.set('weather', createCacheEntry('☁️')); 569 | const migration = new Deferred(); 570 | const getValue2 = new Deferred(); 571 | const value = cachified( 572 | { 573 | cache, 574 | key: 'weather', 575 | async checkValue(value, migrate) { 576 | if (value === '☁️') { 577 | await migration.promise; 578 | return migrate('☀️'); 579 | } 580 | }, 581 | getFreshValue() { 582 | throw new Error('Never'); 583 | }, 584 | }, 585 | reporter, 586 | ); 587 | 588 | const value2 = cachified( 589 | { 590 | cache, 591 | forceFresh: true, 592 | key: 'weather', 593 | getFreshValue() { 594 | return getValue2.promise; 595 | }, 596 | }, 597 | reporter, 598 | ); 599 | 600 | migration.resolve(); 601 | expect(await value).toBe('☀️'); 602 | await delay(1); 603 | expect(cache.get('weather')?.value).toBe('☁️'); 604 | 605 | getValue2.resolve('🌈'); 606 | expect(await value2).toBe('🌈'); 607 | expect(cache.get('weather')?.value).toBe('🌈'); 608 | }); 609 | 610 | it('de-duplicates stale refreshes', async () => { 611 | const cache = new Map(); 612 | const log: string[] = []; 613 | const backgroundTasks: Promise[] = []; 614 | 615 | const getValue = (id: string) => { 616 | return cachified({ 617 | cache, 618 | waitUntil(p) { 619 | backgroundTasks.push(p); 620 | }, 621 | key: `test`, 622 | ttl: 10, 623 | swr: 50, 624 | getFreshValue() { 625 | log.push(id); 626 | return id; 627 | }, 628 | }); 629 | }; 630 | 631 | // Warm up cache 632 | await getValue('1'); 633 | 634 | // These calls are background refreshing. Second one is de-duplicated. 635 | currentTime = 15; 636 | const call2 = getValue('2'); 637 | const call3 = getValue('3'); 638 | 639 | // They resolve with stale value 640 | expect(await call3).toBe('1'); 641 | expect(await call2).toBe('1'); 642 | 643 | await Promise.all(backgroundTasks); 644 | 645 | // Only one refresh was executed 646 | expect(log).toEqual(['1', '2']); 647 | }); 648 | 649 | it('gets different values for different keys', async () => { 650 | const cache = new Map(); 651 | 652 | const value = await cachified({ 653 | cache, 654 | key: 'test', 655 | getFreshValue() { 656 | return 'ONE'; 657 | }, 658 | }); 659 | const value2 = await cachified({ 660 | cache, 661 | key: 'test-2', 662 | getFreshValue() { 663 | return 'TWO'; 664 | }, 665 | }); 666 | 667 | expect(value).toBe('ONE'); 668 | expect(value2).toBe('TWO'); 669 | 670 | // sanity check that test-2 is also cached 671 | const value3 = await cachified({ 672 | cache, 673 | key: 'test-2', 674 | getFreshValue() { 675 | return 'THREE'; 676 | }, 677 | }); 678 | 679 | expect(value3).toBe('TWO'); 680 | }); 681 | 682 | it('gets fresh value when forced to', async () => { 683 | const cache = new Map(); 684 | 685 | const value = await cachified({ 686 | cache, 687 | key: 'test', 688 | getFreshValue() { 689 | return 'ONE'; 690 | }, 691 | }); 692 | const value2 = await cachified({ 693 | cache, 694 | forceFresh: true, 695 | key: 'test', 696 | getFreshValue() { 697 | return 'TWO'; 698 | }, 699 | }); 700 | 701 | expect(value).toBe('ONE'); 702 | expect(value2).toBe('TWO'); 703 | }); 704 | 705 | it('falls back to cache when forced fresh value fails', async () => { 706 | const cache = new Map(); 707 | const reporter = createReporter(); 708 | 709 | cache.set('test', createCacheEntry('ONE')); 710 | const value2 = await cachified( 711 | { 712 | cache, 713 | key: 'test', 714 | forceFresh: true, 715 | getFreshValue: () => { 716 | throw '🤡'; 717 | }, 718 | }, 719 | reporter, 720 | ); 721 | 722 | expect(value2).toBe('ONE'); 723 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 724 | "1. init 725 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 726 | 2. getFreshValueStart 727 | 3. getFreshValueError 728 | {error: '🤡'} 729 | 4. getCachedValueStart 730 | 5. getCachedValueRead 731 | {entry: {metadata: {createdTime: 0, swr: 0, ttl: null}, value: 'ONE'}} 732 | 6. getFreshValueCacheFallback 733 | {value: 'ONE'} 734 | 7. writeFreshValueSuccess 735 | {metadata: {createdTime: 0, swr: 0, ttl: null}, migrated: false, written: true} 736 | 8. done 737 | {value: 'ONE'}" 738 | `); 739 | }); 740 | 741 | it('does not fall back to outdated cache', async () => { 742 | const cache = new Map(); 743 | const reporter = createReporter(); 744 | 745 | cache.set('test', createCacheEntry('ONE', { ttl: 5 })); 746 | currentTime = 15; 747 | const value = cachified( 748 | { 749 | cache, 750 | key: 'test', 751 | forceFresh: true, 752 | fallbackToCache: 10, 753 | getFreshValue: () => { 754 | throw '🤡'; 755 | }, 756 | }, 757 | reporter, 758 | ); 759 | 760 | await expect(value).rejects.toMatchInlineSnapshot(`"🤡"`); 761 | }); 762 | 763 | it('it throws when cache fallback is disabled and getting fresh value fails', async () => { 764 | const cache = new Map(); 765 | 766 | const value1 = await cachified({ 767 | cache, 768 | key: 'test', 769 | getFreshValue: () => 'ONE', 770 | }); 771 | const value2 = cachified({ 772 | cache, 773 | key: 'test', 774 | forceFresh: true, 775 | fallbackToCache: false, 776 | getFreshValue: () => { 777 | throw '👾'; 778 | }, 779 | }); 780 | 781 | expect(value1).toBe('ONE'); 782 | await expect(value2).rejects.toMatchInlineSnapshot(`"👾"`); 783 | }); 784 | 785 | it('handles cache write fails', async () => { 786 | const cache = new Map(); 787 | const setMock = jest.spyOn(cache, 'set'); 788 | const reporter = createReporter(); 789 | let i = 0; 790 | const getValue = () => 791 | cachified( 792 | { 793 | cache, 794 | key: 'test', 795 | getFreshValue: () => `value-${i++}`, 796 | }, 797 | reporter, 798 | ); 799 | 800 | setMock.mockImplementationOnce(() => { 801 | throw '🔥'; 802 | }); 803 | expect(await getValue()).toBe('value-0'); 804 | expect(await getValue()).toBe('value-1'); 805 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 806 | " 1. init 807 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 808 | 2. getCachedValueStart 809 | 3. getCachedValueRead 810 | 4. getCachedValueEmpty 811 | 5. getFreshValueStart 812 | 6. getFreshValueSuccess 813 | {value: 'value-0'} 814 | 7. writeFreshValueError 815 | {error: '🔥'} 816 | 8. done 817 | {value: 'value-0'} 818 | 9. init 819 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 820 | 10. getCachedValueStart 821 | 11. getCachedValueRead 822 | 12. getCachedValueEmpty 823 | 13. getFreshValueStart 824 | 14. getFreshValueSuccess 825 | {value: 'value-1'} 826 | 15. writeFreshValueSuccess 827 | {metadata: {createdTime: 0, swr: 0, ttl: null}, migrated: false, written: true} 828 | 16. done 829 | {value: 'value-1'}" 830 | `); 831 | expect(await getValue()).toBe('value-1'); 832 | }); 833 | 834 | it('gets fresh value when ttl is exceeded', async () => { 835 | const cache = new Map(); 836 | const reporter = createReporter(); 837 | let i = 0; 838 | const getValue = () => 839 | cachified( 840 | { 841 | cache, 842 | key: 'test', 843 | ttl: 5, 844 | getFreshValue: () => `value-${i++}`, 845 | }, 846 | reporter, 847 | ); 848 | 849 | expect(await getValue()).toBe('value-0'); 850 | 851 | // does use cached value since ttl is not exceeded 852 | currentTime = 4; 853 | expect(await getValue()).toBe('value-0'); 854 | 855 | // gets new value because ttl is exceeded 856 | currentTime = 6; 857 | expect(await getValue()).toBe('value-1'); 858 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 859 | " 1. init 860 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: 5}} 861 | 2. getCachedValueStart 862 | 3. getCachedValueRead 863 | 4. getCachedValueEmpty 864 | 5. getFreshValueStart 865 | 6. getFreshValueSuccess 866 | {value: 'value-0'} 867 | 7. writeFreshValueSuccess 868 | {metadata: {createdTime: 0, swr: 0, ttl: 5}, migrated: false, written: true} 869 | 8. done 870 | {value: 'value-0'} 871 | 9. init 872 | {key: 'test', metadata: {createdTime: 4, swr: 0, ttl: 5}} 873 | 10. getCachedValueStart 874 | 11. getCachedValueRead 875 | {entry: {metadata: {createdTime: 0, swr: 0, ttl: 5}, value: 'value-0'}} 876 | 12. getCachedValueSuccess 877 | {migrated: false, value: 'value-0'} 878 | 13. done 879 | {value: 'value-0'} 880 | 14. init 881 | {key: 'test', metadata: {createdTime: 6, swr: 0, ttl: 5}} 882 | 15. getCachedValueStart 883 | 16. getCachedValueRead 884 | {entry: {metadata: {createdTime: 0, swr: 0, ttl: 5}, value: 'value-0'}} 885 | 17. getCachedValueOutdated 886 | {metadata: {createdTime: 0, swr: 0, ttl: 5}, value: 'value-0'} 887 | 18. getFreshValueStart 888 | 19. getFreshValueSuccess 889 | {value: 'value-1'} 890 | 20. writeFreshValueSuccess 891 | {metadata: {createdTime: 6, swr: 0, ttl: 5}, migrated: false, written: true} 892 | 21. done 893 | {value: 'value-1'}" 894 | `); 895 | }); 896 | 897 | it('does not write to cache when ttl is exceeded before value is received', async () => { 898 | const cache = new Map(); 899 | const setMock = jest.spyOn(cache, 'set'); 900 | const reporter = createReporter(); 901 | 902 | const value = await cachified( 903 | { 904 | cache, 905 | key: 'test', 906 | ttl: 5, 907 | getFreshValue() { 908 | currentTime = 6; 909 | return 'ONE'; 910 | }, 911 | }, 912 | reporter, 913 | ); 914 | 915 | expect(value).toBe('ONE'); 916 | expect(setMock).not.toHaveBeenCalled(); 917 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 918 | "1. init 919 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: 5}} 920 | 2. getCachedValueStart 921 | 3. getCachedValueRead 922 | 4. getCachedValueEmpty 923 | 5. getFreshValueStart 924 | 6. getFreshValueSuccess 925 | {value: 'ONE'} 926 | 7. writeFreshValueSuccess 927 | {metadata: {createdTime: 0, swr: 0, ttl: 5}, migrated: false, written: false} 928 | 8. done 929 | {value: 'ONE'}" 930 | `); 931 | }); 932 | 933 | it('reuses pending fresh value for parallel calls', async () => { 934 | const cache = new Map(); 935 | const reporter = createReporter(); 936 | const getValue = ( 937 | getFreshValue: CachifiedOptions['getFreshValue'], 938 | ) => 939 | cachified( 940 | { 941 | cache, 942 | key: 'test', 943 | getFreshValue, 944 | }, 945 | reporter, 946 | ); 947 | 948 | const d = new Deferred(); 949 | const pValue1 = getValue(() => d.promise); 950 | // value from first call is pending so this one is never called 951 | const pValue2 = getValue(() => 'TWO'); 952 | 953 | d.resolve('ONE'); 954 | 955 | expect(await pValue1).toBe('ONE'); 956 | expect(await pValue2).toBe('ONE'); 957 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 958 | " 1. init 959 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 960 | 2. getCachedValueStart 961 | 3. init 962 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 963 | 4. getCachedValueStart 964 | 5. getCachedValueRead 965 | 6. getCachedValueRead 966 | 7. getCachedValueEmpty 967 | 8. getCachedValueEmpty 968 | 9. getFreshValueStart 969 | 10. getFreshValueHookPending 970 | 11. getFreshValueSuccess 971 | {value: 'ONE'} 972 | 12. writeFreshValueSuccess 973 | {metadata: {createdTime: 0, swr: 0, ttl: null}, migrated: false, written: true} 974 | 13. done 975 | {value: 'ONE'} 976 | 14. done 977 | {value: 'ONE'}" 978 | `); 979 | }); 980 | 981 | it('does not use pending values after TTL is over', async () => { 982 | const cache = new Map(); 983 | const reporter = createReporter(); 984 | const getValue = ( 985 | getFreshValue: CachifiedOptions['getFreshValue'], 986 | ) => 987 | cachified( 988 | { 989 | cache, 990 | ttl: 5, 991 | key: 'test', 992 | getFreshValue, 993 | }, 994 | reporter, 995 | ); 996 | 997 | const d = new Deferred(); 998 | const pValue1 = getValue(() => d.promise); 999 | currentTime = 6; 1000 | const pValue2 = getValue(() => 'TWO'); 1001 | 1002 | d.resolve('ONE'); 1003 | expect(await pValue1).toBe('ONE'); 1004 | expect(await pValue2).toBe('TWO'); 1005 | }); 1006 | 1007 | it('supports extending ttl during getFreshValue operation', async () => { 1008 | const cache = new Map(); 1009 | const reporter = createReporter(); 1010 | const getValue = ( 1011 | getFreshValue: CachifiedOptions['getFreshValue'], 1012 | ) => 1013 | cachified( 1014 | { 1015 | cache, 1016 | ttl: 5, 1017 | key: 'test', 1018 | getFreshValue, 1019 | }, 1020 | reporter, 1021 | ); 1022 | 1023 | const firstCallMetaDataD = new Deferred(); 1024 | 1025 | const d = new Deferred(); 1026 | const p1 = getValue(({ metadata }) => { 1027 | metadata.ttl = 10; 1028 | // Don't do this at home kids... 1029 | firstCallMetaDataD.resolve(metadata); 1030 | return d.promise; 1031 | }); 1032 | 1033 | const metadata = await firstCallMetaDataD.promise; 1034 | 1035 | currentTime = 6; 1036 | // First call is still ongoing and initial ttl is over, still we exceeded 1037 | // the ttl in the call so this should not be called ever 1038 | const p2 = getValue(() => { 1039 | throw new Error('Never'); 1040 | }); 1041 | 1042 | // Further exceeding the ttl and resolving first call 1043 | metadata!.ttl = 15; 1044 | d.resolve('ONE'); 1045 | 1046 | expect(await p1).toBe('ONE'); 1047 | expect(await p2).toBe('ONE'); 1048 | 1049 | // now proceed to time between first and second modification of ttl 1050 | currentTime = 13; 1051 | // we still get the cached value from first call 1052 | expect( 1053 | await getValue(() => { 1054 | throw new Error('Never2'); 1055 | }), 1056 | ).toBe('ONE'); 1057 | }); 1058 | 1059 | it('supports bailing out of caching during getFreshValue operation', async () => { 1060 | const cache = new Map(); 1061 | const reporter = createReporter(); 1062 | 1063 | const value = await cachified( 1064 | { 1065 | cache, 1066 | ttl: 5, 1067 | key: 'test', 1068 | getFreshValue({ metadata }) { 1069 | metadata.ttl = -1; 1070 | return null; 1071 | }, 1072 | }, 1073 | reporter, 1074 | ); 1075 | 1076 | expect(value).toBe(null); 1077 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 1078 | "1. init 1079 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: -1}} 1080 | 2. getCachedValueStart 1081 | 3. getCachedValueRead 1082 | 4. getCachedValueEmpty 1083 | 5. getFreshValueStart 1084 | 6. getFreshValueSuccess 1085 | {value: null} 1086 | 7. writeFreshValueSuccess 1087 | {metadata: {createdTime: 0, swr: 0, ttl: -1}, migrated: false, written: false} 1088 | 8. done 1089 | {value: null}" 1090 | `); 1091 | }); 1092 | 1093 | it('resolves earlier pending values with faster responses from later calls', async () => { 1094 | const cache = new Map(); 1095 | const getValue = ( 1096 | getFreshValue: CachifiedOptions['getFreshValue'], 1097 | ) => 1098 | cachified({ 1099 | cache, 1100 | key: 'test', 1101 | ttl: 5, 1102 | getFreshValue, 1103 | }); 1104 | 1105 | const d1 = new Deferred(); 1106 | const pValue1 = getValue(() => d1.promise); 1107 | 1108 | currentTime = 6; 1109 | // value from first call is pending but ttl is also exceeded, get fresh value 1110 | const d2 = new Deferred(); 1111 | const pValue2 = getValue(() => d2.promise); 1112 | 1113 | currentTime = 12; 1114 | // this one delivers the earliest response take it for all pending calls 1115 | const pValue3 = getValue(() => 'THREE'); 1116 | 1117 | expect(await pValue1).toBe('THREE'); 1118 | expect(await pValue2).toBe('THREE'); 1119 | expect(await pValue3).toBe('THREE'); 1120 | 1121 | d1.resolve('ONE'); 1122 | d2.reject('TWO'); 1123 | 1124 | // late responses from earlier calls do not update cache 1125 | expect(await getValue(() => 'FOUR')).toBe('THREE'); 1126 | }); 1127 | 1128 | it('provides access to internal pending values cache', async () => { 1129 | const cache = new Map(); 1130 | const pendingValuesCache = getPendingValuesCache(cache); 1131 | const d = new Deferred(); 1132 | 1133 | cachified({ cache, key: 'test', ttl: 5, getFreshValue: () => d.promise }); 1134 | await delay(0); // pending values are not set immediately 1135 | 1136 | expect(pendingValuesCache.get('test')).toEqual( 1137 | expect.objectContaining({ 1138 | metadata: anyMetadata, 1139 | value: expect.any(Promise), 1140 | resolve: expect.any(Function), 1141 | }), 1142 | ); 1143 | }); 1144 | 1145 | it('uses stale cache while revalidating', async () => { 1146 | const cache = new Map(); 1147 | const reporter = createReporter(); 1148 | let i = 0; 1149 | const getFreshValue = jest.fn(() => `value-${i++}`); 1150 | const waitUntil = jest.fn(); 1151 | const getValue = () => 1152 | cachified( 1153 | { 1154 | cache, 1155 | key: 'test', 1156 | ttl: 5, 1157 | staleWhileRevalidate: 10, 1158 | getFreshValue, 1159 | waitUntil, 1160 | }, 1161 | reporter, 1162 | ); 1163 | 1164 | expect(await getValue()).toBe('value-0'); 1165 | currentTime = 6; 1166 | // receive cached response since call exceeds ttl but is in stale while revalidate range 1167 | expect(cache.get('test')?.value).toBe('value-0'); 1168 | expect(await getValue()).toBe('value-0'); 1169 | // wait for promise (revalidation is done in background) 1170 | expect(waitUntil).toHaveBeenCalledTimes(1); 1171 | expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise)); 1172 | await waitUntil.mock.calls[0][0]; 1173 | // We don't care about the latter calls 1174 | const calls = [...reporter.mock.calls]; 1175 | 1176 | // next call gets the revalidated response 1177 | expect(cache.get('test')?.value).toBe('value-1'); 1178 | expect(await getValue()).toBe('value-1'); 1179 | 1180 | const getFreshValueCalls = getFreshValue.mock.calls as any as Parameters< 1181 | GetFreshValue 1182 | >[]; 1183 | expect(getFreshValue).toHaveBeenCalledTimes(2); 1184 | 1185 | // Does pass info if it's a stale while revalidate call 1186 | expect(getFreshValueCalls[0][0].background).toBe(false); 1187 | expect(getFreshValueCalls[1][0].background).toBe(true); 1188 | 1189 | // Does not deliver stale cache when swr is exceeded 1190 | currentTime = 30; 1191 | expect(await getValue()).toBe('value-2'); 1192 | expect(getFreshValue).toHaveBeenCalledTimes(3); 1193 | 1194 | expect(report(calls)).toMatchInlineSnapshot(` 1195 | " 1. init 1196 | {key: 'test', metadata: {createdTime: 0, swr: 10, ttl: 5}} 1197 | 2. getCachedValueStart 1198 | 3. getCachedValueRead 1199 | 4. getCachedValueEmpty 1200 | 5. getFreshValueStart 1201 | 6. getFreshValueSuccess 1202 | {value: 'value-0'} 1203 | 7. writeFreshValueSuccess 1204 | {metadata: {createdTime: 0, swr: 10, ttl: 5}, migrated: false, written: true} 1205 | 8. done 1206 | {value: 'value-0'} 1207 | 9. init 1208 | {key: 'test', metadata: {createdTime: 6, swr: 10, ttl: 5}} 1209 | 10. getCachedValueStart 1210 | 11. getCachedValueRead 1211 | {entry: {metadata: {createdTime: 0, swr: 10, ttl: 5}, value: 'value-0'}} 1212 | 12. getCachedValueSuccess 1213 | {migrated: false, value: 'value-0'} 1214 | 13. done 1215 | {value: 'value-0'} 1216 | 14. refreshValueStart 1217 | 15. refreshValueSuccess 1218 | {value: 'value-1'}" 1219 | `); 1220 | }); 1221 | 1222 | it('handles negative staleWhileRevalidate gracefully', async () => { 1223 | const cache = new Map(); 1224 | let i = 0; 1225 | const getFreshValue = jest.fn(() => `value-${i++}`); 1226 | const getValue = () => 1227 | cachified({ 1228 | cache, 1229 | key: 'test', 1230 | ttl: 5, 1231 | staleWhileRevalidate: -1, 1232 | getFreshValue, 1233 | }); 1234 | 1235 | expect(await getValue()).toBe('value-0'); 1236 | currentTime = 6; 1237 | expect(await getValue()).toBe('value-1'); 1238 | }); 1239 | 1240 | it('falls back to deprecated swv when swr is not present', async () => { 1241 | const cache = new Map(); 1242 | let i = 0; 1243 | const getFreshValue = jest.fn(() => `value-${i++}`); 1244 | const oldCacheEntry = createCacheEntry(`value-${i++}`, { swr: 5, ttl: 5 }); 1245 | // @ts-ignore (we actually want to create an entry with a now deprecated signature) 1246 | oldCacheEntry.metadata.swv = oldCacheEntry.metadata.swr; 1247 | delete oldCacheEntry.metadata.swr; 1248 | cache.set('test', oldCacheEntry); 1249 | 1250 | const getValue = () => 1251 | cachified({ 1252 | cache, 1253 | key: 'test', 1254 | ttl: 5, 1255 | swr: 5, 1256 | getFreshValue, 1257 | }); 1258 | 1259 | expect(await getValue()).toBe('value-0'); 1260 | currentTime = 6; 1261 | expect(await getValue()).toBe('value-0'); 1262 | await delay(1); 1263 | expect(await getValue()).toBe('value-1'); 1264 | expect(getFreshValue).toHaveBeenCalledTimes(1); 1265 | }); 1266 | 1267 | it('supports infinite stale while revalidate', async () => { 1268 | const cache = new Map(); 1269 | let i = 0; 1270 | const getFreshValue = jest.fn(() => `value-${i++}`); 1271 | const getValue = () => 1272 | cachified({ 1273 | cache, 1274 | key: 'test', 1275 | ttl: 5, 1276 | staleWhileRevalidate: Infinity, 1277 | getFreshValue, 1278 | }); 1279 | 1280 | expect(await getValue()).toBe('value-0'); 1281 | currentTime = 6; 1282 | expect(await getValue()).toBe('value-0'); 1283 | await delay(0); 1284 | expect(await getValue()).toBe('value-1'); 1285 | expect(getFreshValue).toHaveBeenCalledTimes(2); 1286 | 1287 | // Does deliver stale cache in the far future 1288 | currentTime = Infinity; 1289 | expect(await getValue()).toBe('value-1'); 1290 | await delay(0); 1291 | expect(await getValue()).toBe('value-2'); 1292 | expect(getFreshValue).toHaveBeenCalledTimes(3); 1293 | }); 1294 | 1295 | it('ignores errors when revalidating cache in the background', async () => { 1296 | const cache = new Map(); 1297 | const reporter = createReporter(); 1298 | let i = 0; 1299 | const getFreshValue = jest.fn(() => `value-${i++}`); 1300 | const getValue = () => 1301 | cachified( 1302 | { 1303 | cache, 1304 | key: 'test', 1305 | ttl: 5, 1306 | staleWhileRevalidate: 10, 1307 | getFreshValue, 1308 | }, 1309 | reporter, 1310 | ); 1311 | 1312 | expect(await getValue()).toBe('value-0'); 1313 | currentTime = 6; 1314 | getFreshValue.mockImplementationOnce(() => { 1315 | throw new Error('💩'); 1316 | }); 1317 | // this triggers revalidation which errors but we don't care 1318 | expect(await getValue()).toBe('value-0'); 1319 | await delay(0); 1320 | // we don't care about later calls 1321 | const calls = [...reporter.mock.calls]; 1322 | 1323 | // this again triggers revalidation this time with no error 1324 | expect(await getValue()).toBe('value-0'); 1325 | await delay(0); 1326 | // next call gets the fresh value 1327 | expect(await getValue()).toBe('value-1'); 1328 | expect(getFreshValue).toHaveBeenCalledTimes(3); 1329 | expect(report(calls)).toMatchInlineSnapshot(` 1330 | " 1. init 1331 | {key: 'test', metadata: {createdTime: 0, swr: 10, ttl: 5}} 1332 | 2. getCachedValueStart 1333 | 3. getCachedValueRead 1334 | 4. getCachedValueEmpty 1335 | 5. getFreshValueStart 1336 | 6. getFreshValueSuccess 1337 | {value: 'value-0'} 1338 | 7. writeFreshValueSuccess 1339 | {metadata: {createdTime: 0, swr: 10, ttl: 5}, migrated: false, written: true} 1340 | 8. done 1341 | {value: 'value-0'} 1342 | 9. init 1343 | {key: 'test', metadata: {createdTime: 6, swr: 10, ttl: 5}} 1344 | 10. getCachedValueStart 1345 | 11. getCachedValueRead 1346 | {entry: {metadata: {createdTime: 0, swr: 10, ttl: 5}, value: 'value-0'}} 1347 | 12. getCachedValueSuccess 1348 | {migrated: false, value: 'value-0'} 1349 | 13. done 1350 | {value: 'value-0'} 1351 | 14. refreshValueStart 1352 | 15. refreshValueError 1353 | {error: [Error: 💩]}" 1354 | `); 1355 | }); 1356 | 1357 | it('gets fresh value in case cached one does not meet value check', async () => { 1358 | const cache = new Map(); 1359 | const reporter = createReporter(); 1360 | const reporter2 = createReporter(); 1361 | 1362 | cache.set('test', createCacheEntry('ONE')); 1363 | const value = await cachified( 1364 | { 1365 | cache, 1366 | key: 'test', 1367 | checkValue(value) { 1368 | return value === 'TWO'; 1369 | }, 1370 | getFreshValue() { 1371 | return 'TWO'; 1372 | }, 1373 | }, 1374 | reporter, 1375 | ); 1376 | 1377 | expect(value).toBe('TWO'); 1378 | expect(report(reporter.mock.calls)).toMatchInlineSnapshot(` 1379 | " 1. init 1380 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 1381 | 2. getCachedValueStart 1382 | 3. getCachedValueRead 1383 | {entry: {metadata: {createdTime: 0, swr: 0, ttl: null}, value: 'ONE'}} 1384 | 4. checkCachedValueErrorObj 1385 | {reason: 'unknown'} 1386 | 5. checkCachedValueError 1387 | {reason: 'unknown'} 1388 | 6. getFreshValueStart 1389 | 7. getFreshValueSuccess 1390 | {value: 'TWO'} 1391 | 8. writeFreshValueSuccess 1392 | {metadata: {createdTime: 0, swr: 0, ttl: null}, migrated: false, written: true} 1393 | 9. done 1394 | {value: 'TWO'}" 1395 | `); 1396 | 1397 | // the following lines only exist for 100% coverage 😅 1398 | cache.set('test', createCacheEntry('ONE')); 1399 | const value2 = await cachified( 1400 | { 1401 | cache, 1402 | key: 'test', 1403 | checkValue(value) { 1404 | return value === 'TWO' ? true : '🖕'; 1405 | }, 1406 | getFreshValue() { 1407 | return 'TWO'; 1408 | }, 1409 | }, 1410 | reporter2, 1411 | ); 1412 | expect(value2).toBe('TWO'); 1413 | expect(report(reporter2.mock.calls)).toMatchInlineSnapshot(` 1414 | " 1. init 1415 | {key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}} 1416 | 2. getCachedValueStart 1417 | 3. getCachedValueRead 1418 | {entry: {metadata: {createdTime: 0, swr: 0, ttl: null}, value: 'ONE'}} 1419 | 4. checkCachedValueErrorObj 1420 | {reason: '🖕'} 1421 | 5. checkCachedValueError 1422 | {reason: '🖕'} 1423 | 6. getFreshValueStart 1424 | 7. getFreshValueSuccess 1425 | {value: 'TWO'} 1426 | 8. writeFreshValueSuccess 1427 | {metadata: {createdTime: 0, swr: 0, ttl: null}, migrated: false, written: true} 1428 | 9. done 1429 | {value: 'TWO'}" 1430 | `); 1431 | }); 1432 | 1433 | it('supports batch-getting fresh values', async () => { 1434 | const cache = new Map(); 1435 | cache.set('test-2', createCacheEntry('YOLO!', { swr: null })); 1436 | const getValues = jest.fn((indexes: number[]) => 1437 | indexes.map((i) => `value-${i}`), 1438 | ); 1439 | const batch = createBatch(getValues); 1440 | 1441 | const values = await Promise.all( 1442 | [1, 2, 3].map((index) => 1443 | cachified({ 1444 | cache, 1445 | key: `test-${index}`, 1446 | getFreshValue: batch.add(index), 1447 | }), 1448 | ), 1449 | ); 1450 | 1451 | // It's not possible to re-use batches 1452 | expect(() => { 1453 | batch.add(77); 1454 | }).toThrowErrorMatchingInlineSnapshot( 1455 | `"Can not add to batch after submission"`, 1456 | ); 1457 | 1458 | expect(values).toEqual(['value-1', 'YOLO!', 'value-3']); 1459 | expect(getValues).toHaveBeenCalledTimes(1); 1460 | expect(getValues).toHaveBeenCalledWith([1, 3], [anyMetadata, anyMetadata]); 1461 | }); 1462 | 1463 | it('rejects all values when batch get fails', async () => { 1464 | const cache = new Map(); 1465 | 1466 | const batch = createBatch(() => { 1467 | throw new Error('🥊'); 1468 | }); 1469 | 1470 | const values = [1, 2, 3].map((index) => 1471 | cachified({ 1472 | cache, 1473 | key: `test-${index}`, 1474 | getFreshValue: batch.add(index), 1475 | }), 1476 | ); 1477 | 1478 | await expect(values[0]).rejects.toMatchInlineSnapshot(`[Error: 🥊]`); 1479 | await expect(values[1]).rejects.toMatchInlineSnapshot(`[Error: 🥊]`); 1480 | await expect(values[2]).rejects.toMatchInlineSnapshot(`[Error: 🥊]`); 1481 | }); 1482 | 1483 | it('supports manual submission of batch', async () => { 1484 | const cache = new Map(); 1485 | const getValues = jest.fn((indexes: (number | string)[]) => 1486 | indexes.map((i) => `value-${i}`), 1487 | ); 1488 | const batch = createBatch(getValues, false); 1489 | 1490 | const valuesP = Promise.all( 1491 | [1, 'seven'].map((index) => 1492 | cachified({ 1493 | cache, 1494 | key: `test-${index}`, 1495 | getFreshValue: batch.add(index), 1496 | }), 1497 | ), 1498 | ); 1499 | await delay(0); 1500 | expect(getValues).not.toHaveBeenCalled(); 1501 | 1502 | await batch.submit(); 1503 | 1504 | expect(await valuesP).toEqual(['value-1', 'value-seven']); 1505 | expect(getValues).toHaveBeenCalledTimes(1); 1506 | expect(getValues).toHaveBeenCalledWith( 1507 | [1, 'seven'], 1508 | [anyMetadata, anyMetadata], 1509 | ); 1510 | }); 1511 | 1512 | it('can edit metadata for single batch values', async () => { 1513 | const cache = new Map(); 1514 | const getValues = jest.fn(() => [ 1515 | 'one', 1516 | null /* pretend this value does not exist (yet) */, 1517 | ]); 1518 | const batch = createBatch(getValues); 1519 | 1520 | const values = await Promise.all( 1521 | [1, 2].map((index) => 1522 | cachified({ 1523 | cache, 1524 | key: `test-${index}`, 1525 | ttl: 5, 1526 | getFreshValue: batch.add(index, ({ value, metadata }) => { 1527 | if (value === null) { 1528 | metadata.ttl = -1; 1529 | } 1530 | }), 1531 | }), 1532 | ), 1533 | ); 1534 | 1535 | expect(values).toEqual(['one', null]); 1536 | expect(cache.get('test-1')).toEqual({ 1537 | metadata: { createdTime: 0, swr: 0, ttl: 5 }, 1538 | value: 'one', 1539 | }); 1540 | /* Has not been written to cache */ 1541 | expect(cache.get('test-2')).toBe(undefined); 1542 | }); 1543 | 1544 | it('de-duplicates batched cache calls', async () => { 1545 | const cache = new Map(); 1546 | 1547 | function getValues(indexes: number[], callId: number) { 1548 | const batch = createBatch((freshIndexes: number[]) => 1549 | freshIndexes.map((i) => `value-${i}-call-${callId}`), 1550 | ); 1551 | 1552 | return Promise.all( 1553 | indexes.map((index) => 1554 | cachified({ 1555 | cache, 1556 | key: `test-${index}`, 1557 | ttl: Infinity, 1558 | getFreshValue: batch.add(index), 1559 | }), 1560 | ), 1561 | ); 1562 | } 1563 | 1564 | const batch1 = getValues([1, 2, 3], 1); 1565 | const batch2 = getValues([1, 2, 5], 2); 1566 | 1567 | expect(await batch1).toEqual([ 1568 | 'value-1-call-1', 1569 | 'value-2-call-1', 1570 | 'value-3-call-1', 1571 | ]); 1572 | expect(await batch2).toEqual([ 1573 | 'value-1-call-1', 1574 | 'value-2-call-1', 1575 | 'value-5-call-2', 1576 | ]); 1577 | }); 1578 | 1579 | it('de-duplicates duplicated keys within a batch', async () => { 1580 | const cache = new Map(); 1581 | 1582 | let i = 0; 1583 | const batch = createBatch((freshIndexes: number[]) => 1584 | freshIndexes.map((j) => `value-${j}-call-${i++}`), 1585 | ); 1586 | 1587 | const results = await Promise.all( 1588 | [1, 2, 3, 1].map((index) => 1589 | cachified({ 1590 | cache, 1591 | key: `test-${index}`, 1592 | ttl: Infinity, 1593 | getFreshValue: batch.add(index), 1594 | }), 1595 | ), 1596 | ); 1597 | 1598 | expect(results).toEqual([ 1599 | 'value-1-call-0', 1600 | 'value-2-call-1', 1601 | 'value-3-call-2', 1602 | 'value-1-call-0', 1603 | ]); 1604 | }); 1605 | 1606 | it('does not invoke onValue when value comes from cache', async () => { 1607 | const cache = new Map(); 1608 | const onValue = jest.fn(); 1609 | const getValues = jest.fn(() => ['two']); 1610 | const batch = createBatch(getValues); 1611 | 1612 | cache.set('test-1', createCacheEntry('one')); 1613 | 1614 | const value = await cachified({ 1615 | cache, 1616 | key: `test-1`, 1617 | getFreshValue: batch.add(1, onValue), 1618 | }); 1619 | 1620 | expect(value).toEqual('one'); 1621 | expect(onValue).not.toHaveBeenCalled(); 1622 | expect(getValues).not.toHaveBeenCalled(); 1623 | }); 1624 | 1625 | it('does not use faulty cache entries', async () => { 1626 | expect.assertions(23); 1627 | const cache = new Map(); 1628 | 1629 | const getValue = (reporter: CreateReporter) => 1630 | cachified( 1631 | { 1632 | cache, 1633 | key: 'test', 1634 | getFreshValue() { 1635 | return 'ONE'; 1636 | }, 1637 | }, 1638 | reporter, 1639 | ); 1640 | 1641 | cache.set('test', 'THIS IS NOT AN OBJECT'); 1642 | expect( 1643 | await getValue(() => (event) => { 1644 | if (event.name === 'getCachedValueError') { 1645 | expect(event.error).toMatchInlineSnapshot( 1646 | `[Error: Cache entry for test is not a cache entry object, it's a string]`, 1647 | ); 1648 | } 1649 | }), 1650 | ).toBe('ONE'); 1651 | 1652 | cache.set('test', { metadata: { ttl: null, createdTime: Date.now() } }); 1653 | expect( 1654 | await getValue(() => (event) => { 1655 | if (event.name === 'getCachedValueError') { 1656 | expect(event.error).toMatchInlineSnapshot( 1657 | `[Error: Cache entry for for test does not have a value property]`, 1658 | ); 1659 | } 1660 | }), 1661 | ).toBe('ONE'); 1662 | 1663 | const wrongMetadata = [ 1664 | {}, // Missing 1665 | { metadata: '' }, // Not an object 1666 | { metadata: null }, // YEAH... 1667 | { metadata: [] }, // Also not the kind of object we like 1668 | { metadata: {} }, // empty object... 1669 | { metadata: { ttl: 60 } }, // missing created time 1670 | { metadata: { createdTime: 'yesterday' } }, // wrong created time 1671 | { metadata: { ttl: '1h', createdTime: 1234 } }, // wrong ttl 1672 | { metadata: { swr: '1y', createdTime: 1234 } }, // wrong swr 1673 | ]; 1674 | for (let metadata of wrongMetadata) { 1675 | cache.set('test', { value: 'FOUR', ...metadata }); 1676 | expect( 1677 | await getValue(() => (event) => { 1678 | if (event.name === 'getCachedValueError') { 1679 | expect(event.error).toMatchInlineSnapshot( 1680 | `[Error: Cache entry for test does not have valid metadata property]`, 1681 | ); 1682 | } 1683 | }), 1684 | ).toBe('ONE'); 1685 | } 1686 | 1687 | // sanity check that we can set a valid entry to cache manually 1688 | cache.set('test', { 1689 | value: 'FOUR', 1690 | metadata: { ttl: null, swr: null, createdTime: Date.now() }, 1691 | }); 1692 | expect(await getValue(() => () => {})).toBe('FOUR'); 1693 | }); 1694 | 1695 | it('supports creating pre-configured cachified functions', async () => { 1696 | const configuredCachified = configure({ 1697 | cache: new Map(), 1698 | }); 1699 | 1700 | const value = await configuredCachified({ 1701 | key: 'test', 1702 | // look mom, no cache! 1703 | getFreshValue() { 1704 | return 'ONE'; 1705 | }, 1706 | }); 1707 | 1708 | expect(value).toBe('ONE'); 1709 | }); 1710 | 1711 | it('supports trace ids', async () => { 1712 | expect.assertions(7); 1713 | 1714 | const cache = new Map(); 1715 | const traceId1 = Symbol(); 1716 | const d = new Deferred(); 1717 | 1718 | const value = cachified({ 1719 | cache, 1720 | key: 'test-1', 1721 | ttl: 200, 1722 | traceId: traceId1, 1723 | async getFreshValue({ metadata: { traceId } }) { 1724 | // in getFreshValue 1725 | expect(traceId).toBe(traceId1); 1726 | return d.promise; 1727 | }, 1728 | }); 1729 | await delay(0); 1730 | 1731 | // on pending values cache 1732 | expect(getPendingValuesCache(cache).get('test-1')?.metadata.traceId).toBe( 1733 | traceId1, 1734 | ); 1735 | 1736 | d.resolve('ONE'); 1737 | expect(await value).toBe('ONE'); 1738 | 1739 | // on cache entry 1740 | expect(cache.get('test-1')?.metadata.traceId).toBe(traceId1); 1741 | 1742 | const traceId2 = 'some-string-id'; 1743 | 1744 | // in batch getFreshValues 1745 | const batch = createBatch((freshIndexes, metadata) => { 1746 | expect(metadata[0].traceId).toBe(traceId2); 1747 | return freshIndexes.map((i) => `value-${i}`); 1748 | }); 1749 | 1750 | const createReporter = jest.fn(() => () => {}); 1751 | 1752 | await cachified( 1753 | { 1754 | cache, 1755 | key: 'test-2', 1756 | ttl: 200, 1757 | traceId: traceId2, 1758 | getFreshValue: batch.add(1), 1759 | }, 1760 | createReporter, 1761 | ); 1762 | 1763 | expect(cache.get('test-2')?.metadata.traceId).toBe(traceId2); 1764 | 1765 | expect(createReporter).toHaveBeenCalledWith( 1766 | expect.objectContaining({ 1767 | traceId: traceId2, 1768 | metadata: expect.objectContaining({ 1769 | traceId: traceId2, 1770 | }), 1771 | }), 1772 | ); 1773 | }); 1774 | }); 1775 | 1776 | function createReporter() { 1777 | const report = jest.fn(); 1778 | const creator = ({ key, metadata }: Omit, 'report'>) => { 1779 | report({ name: 'init', key, metadata }); 1780 | return report; 1781 | }; 1782 | creator.mock = report.mock; 1783 | return creator; 1784 | } 1785 | --------------------------------------------------------------------------------