@quell/server
Version:
Quell is an open-source NPM package providing a light-weight caching layer implementation and cache invalidation for GraphQL responses on both the client- and server-side. Use Quell to prevent redundant client-side API requests and to minimize costly serv
451 lines (450 loc) • 25.7 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.QuellCache = void 0;
const parser_1 = require("graphql/language/parser");
const redisConnection_1 = require("./helpers/redisConnection");
const graphql_1 = require("graphql");
const cacheUtils_1 = require("./helpers/cacheUtils");
// import middleware functions
const rateLimiter_1 = require("./middleware/rateLimiter");
const costLimit_1 = require("./middleware/costLimit");
const depthLimit_1 = require("./middleware/depthLimit");
// Import cache operations
const readCache_1 = require("./cacheOperations/readCache");
const writeCache_1 = require("./cacheOperations/writeCache");
const normalizeCache_1 = require("./cacheOperations/normalizeCache");
const updateCache_1 = require("./cacheOperations/updateCache");
const invalidateCache_1 = require("./cacheOperations/invalidateCache");
const buildCache_1 = require("./cacheOperations/buildCache");
// Import helper functions
const quellHelpers_1 = require("./helpers/quellHelpers");
const schemaHelpers_1 = require("./helpers/schemaHelpers");
/*
* Note: This file is identical to the main quell-server file, except that the
* rateLimiter, depthLimit, and costLimit have been modified to allow the limits
* to be set in the request body to allow for demoing these features.
*/
const defaultCostParams = {
maxCost: 5000,
mutationCost: 5,
objectCost: 2,
scalarCost: 1,
depthCostFactor: 1.5,
maxDepth: 10,
ipRate: 3, // requests allowed per second
};
let idCache = {};
/**
* Creates a QuellCache instance that provides middleware for caching between the graphQL endpoint and
* front-end requests, connects to redis cloud store via user-specified parameters.
* - If there is no cache expiration provided by the user, cacheExpiration defaults to 14 days in seconds.
* - If there are no cost parameters provided by the user, costParameters is given the default values.
* - If redisPort, redisHost, and redisPassword are omitted, will use local Redis instance.
* See https://redis.io/docs/getting-started/installation/ for instructions on installing Redis and starting a Redis server.
* @param {ConstructorOptions} options - The options to use for the cache.
* @param {GraphQLSchema} options.schema - GraphQL defined schema that is used to facilitate caching by providing valid queries,
* mutations, and fields.
* @param {number} [options.cacheExpiration=1209600] - Time in seconds for redis values to be evicted from the cache. Defaults to 14 days.
* @param {CostParamsType} [options.costParameters=defaultCostParams] - The cost parameters to use for caching. Defaults to:
* - maxCost: 5000 (maximum cost allowed before a request is rejected)
* - mutationCost: 5 (cost of a mutation)
* - objectCost: 2 (cost of retrieving an object)
* - scalarCost: 1 (cost of retrieving a scalar)
* - depthCostFactor: 1.5 (multiplicative cost of each depth level)
* - maxDepth: 10 (depth limit parameter)
* - ipRate: 3 (requests allowed per second)
* @param {number} options.redisPort - (optional) The Redis port to connect to.
* @param {string} options.redisHost - (optional) The Redis host URI to connect to.
* @param {string} options.redisPassword - (optional) The Redis password to the host URI.
* @example // Omit redisPort, redisHost, and redisPassword to use a local Redis instance.
* const quellCache = new QuellCache({
* schema: schema,
* cacheExpiration: 3600, // 1 hour in seconds
* });
*/
class QuellCache {
constructor({ schema, cacheExpiration = 1209600, // Default expiry time is 14 days in seconds
costParameters = defaultCostParams, redisPort, redisHost, redisPassword, }) {
// Convert schema to a standardized format
const standardizedSchema = (0, schemaHelpers_1.anySchemaToQuellSchema)(schema);
console.log("+++++++++++++++++++");
// Initialize schema-related properties
this.schema = schema;
this.queryMap = (0, schemaHelpers_1.getQueryMap)(standardizedSchema);
this.mutationMap = (0, schemaHelpers_1.getMutationMap)(standardizedSchema);
this.fieldsMap = (0, schemaHelpers_1.getFieldsMap)(standardizedSchema);
// Initialize cache properties
this.idCache = idCache;
this.redisCache = redisConnection_1.redisCacheMain;
//connect to Redis
if (!this.redisCache.isOpen) {
this.redisCache.connect().catch((err) => {
console.error('Redis connection error in QuellCache constructor:', err);
});
}
this.redisReadBatchSize = 10;
this.cacheExpiration = cacheExpiration;
// Initialize cost parameters
this.costParameters = Object.assign(defaultCostParams, costParameters);
// Initialize middleware
this.costLimit = (0, costLimit_1.createCostLimit)({
costParameters: this.costParameters,
schema: this.schema, // if needed
});
this.depthLimit = (0, depthLimit_1.createDepthLimit)({
maxDepth: this.costParameters.maxDepth,
});
this.rateLimiter = (0, rateLimiter_1.createRateLimiter)({
ipRate: this.costParameters.ipRate,
redisCache: this.redisCache,
});
// Initialize invalidation operations
this.clearCache = (0, invalidateCache_1.createClearCache)({
redisCache: this.redisCache,
idCache: this.idCache,
});
this.deleteCacheById = (0, invalidateCache_1.createDeleteCacheById)({
redisCache: this.redisCache,
});
this.clearAllCaches = (0, invalidateCache_1.createClearAllCaches)({
redisCache: this.redisCache,
idCache: this.idCache,
});
// Initialize read operations
this.generateCacheID = (0, readCache_1.createGenerateCacheID)();
this.buildFromCache = (0, readCache_1.createBuildFromCache)({
redisCache: this.redisCache,
redisReadBatchSize: this.redisReadBatchSize,
idCache: this.idCache,
generateCacheID: this.generateCacheID,
});
// Initialize write operations
this.updateIdCache = (0, writeCache_1.createUpdateIdCache)({
redisCache: this.redisCache,
cacheExpiration: this.cacheExpiration,
idCache: this.idCache,
});
this.writeToCache = (0, writeCache_1.createWriteToCache)({
redisCache: this.redisCache,
cacheExpiration: this.cacheExpiration,
idCache: this.idCache,
});
this.normalizeForCache = (0, normalizeCache_1.createNormalizeForCache)({
writeToCache: this.writeToCache,
updateIdCache: this.updateIdCache,
});
// Initialize update operations
this.updateCacheByMutation = (0, updateCache_1.createUpdateCacheByMutation)({
redisCache: this.redisCache,
queryMap: this.queryMap,
writeToCache: this.writeToCache,
deleteCacheById: this.deleteCacheById,
});
// Initialize build operations
this.buildCacheFromResponse = (0, buildCache_1.createBuildCacheFromResponse)({
queryMap: this.queryMap,
cacheExpiration: this.cacheExpiration,
writeToCache: this.writeToCache,
normalizeForCache: this.normalizeForCache,
});
this.buildCacheFromMergedResponse = (0, buildCache_1.createBuildCacheFromMergedResponse)({
queryMap: this.queryMap,
cacheExpiration: this.cacheExpiration,
writeToCache: this.writeToCache,
normalizeForCache: this.normalizeForCache,
});
this.handleQueryCaching = (0, buildCache_1.createHandleQueryCaching)({
queryMap: this.queryMap,
cacheExpiration: this.cacheExpiration,
writeToCache: this.writeToCache,
normalizeForCache: this.normalizeForCache,
buildCacheFromResponse: this.buildCacheFromResponse,
buildCacheFromMergedResponse: this.buildCacheFromMergedResponse,
});
this.query = this.query.bind(this);
}
/**
* The class's controller method. It:
* - reads the query string from the request object,
* - tries to construct a response from cache,
* - reformulates a query for any data not in cache,
* - passes the reformulated query to the graphql library to resolve,
* - joins the cached and uncached responses,
* - decomposes and caches the joined query, and
* - attaches the joined response to the response object before passing control to the next middleware.
* @param {Request} req - Express request object, including request body with GraphQL query string.
* @param {Response} res - Express response object, will carry query response to next middleware.
* @param {NextFunction} next - Express next middleware function, invoked when QuellCache completes its work.
*/
query(req, res, next) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
// console.log('***RUNNING QUERY***');
// Return an error if no query is found on the request.
if (!req.body.query) {
next((0, cacheUtils_1.createServerError)("Error: no GraphQL query found on request body, inside rateLimiter", 400, "Error in rateLimiter: Bad Request. Check server log for more details."));
return;
}
// Retrieve GraphQL query string from request body.
const queryString = req.body.query;
// console.log('+++QUELL+++ QUERY STRING:', queryString);
// Create the abstract syntax tree with graphql-js parser.
// If depth limit or cost limit were implemented, then we can get the AST and parsed AST from res.locals.
const AST = res.locals.AST
? res.locals.AST
: (0, parser_1.parse)(queryString);
// console.log('+++QUELL+++ AST:', parse(queryString))
// Create response prototype, operation type, and fragments object.
// The response prototype is used as a template for most operations in Quell including caching, building modified requests, and more.
const { proto, operationType, frags } = (_a = res.locals.parsedAST) !== null && _a !== void 0 ? _a : (0, quellHelpers_1.parseAST)(AST);
// console.log('+++QUELL+++ PARSED AST:', parseAST(AST))
// console.log('PROTO', proto)
console.log("+++QUELL+++ OPERATION TYPE", operationType);
// console.log('FRAGS', frags)
// Determine if Quell is able to handle the operation.
// Quell can handle mutations and queries.
if (operationType === "unQuellable") {
/*
* If the operation is unQuellable (cannot be cached), execute the operation,
* add the result to the response, and return.
*/
(0, graphql_1.graphql)({ schema: this.schema, source: queryString })
.then((queryResult) => {
res.locals.queryResponse = queryResult;
return next();
})
.catch((error) => {
const err = {
log: `Error inside catch block of operationType === unQuellable of query, ${error}`,
status: 400,
message: {
err: `GraphQL query Error: Check server log for more details. Error: ${error}`,
},
};
return next(err);
});
}
else if (operationType === "noID") {
/*
* If ID was not included in the query, it will not be included in the cache. Execute the GraphQL
* operation without writing the result to cache and return.
*/
// FIXME: Can possibly modify query to ALWAYS have an ID but not necessarily return it back to client
// unless they also queried for it.
(0, graphql_1.graphql)({ schema: this.schema, source: queryString })
.then((queryResult) => {
res.locals.queryResponse = queryResult;
return next();
})
.catch((error) => {
const err = {
log: `Error inside catch block of operationType === noID of query, ${error}`,
status: 400,
message: {
err: `GraphQL query Error: Check server log for more details. Error: ${error}`,
},
};
return next(err);
});
/*
* The code from here to the end of the current if block was left over from a previous
* implementation and is not currently being used.
* For the previous implementation: if the ID was not included, used the cache result
* if the query string was found in the Redis cache. Otherwise, used the result of
* executing the operation and stored the result in cache.
*/
// Check Redis for the query string .
// let redisValue: RedisValue = await getFromRedis(
// queryString,
// this.redisCache
// );
// if (redisValue != null) {
// // If the query string is found in Redis, add the result to the response and return.
// redisValue = JSON.parse(redisValue);
// res.locals.queryResponse = redisValue;
// return next();
// } else {
// // Execute the operation, add the result to the response, write the query string and result to cache, and return.
// graphql({ schema: this.schema, source: queryString })
// .then((queryResult: ExecutionResult): void => {
// res.locals.queryResponse = queryResult;
// this.writeToCache(queryString, queryResult);
// return next();
// })
// .catch((error: Error): void => {
// const err: ServerErrorType = {
// log: `Error inside catch block of operationType === noID of query, graphQL query failed, ${error}`,
// status: 400,
// message: {
// err: `GraphQL query Error: Check server log for more details. Error: ${error}`,
// },
// };
// return next(err);
// });
// }
}
else if (operationType === "mutation") {
// TODO: If the operation is a mutation, we are currently clearing the cache because it is stale.
// The goal would be to instead have a normalized cache and update the cache following a mutation.
this.redisCache.flushAll();
idCache = {};
// Execute the operation and add the result to the response.
(0, graphql_1.graphql)({ schema: this.schema, source: queryString })
.then((databaseResponse) => {
res.locals.queryResponse = databaseResponse;
// Determine if the query string is a valid mutation in the schema.
// Loop through the mutations in the mutationMap.
for (const mutation in this.mutationMap) {
// If any mutation from the mutationMap is found on the proto, the query string includes
// a valid mutation. Update the mutation query object, name, type variables. Update the cache with the response.
// We don't need to wait until writeToCache is finished.
if (Object.prototype.hasOwnProperty.call(proto, mutation)) {
const mutationName = mutation;
const mutationType = this.mutationMap[mutation];
const mutationQueryObject = proto[mutation];
this.updateCacheByMutation(databaseResponse, mutationName, mutationType, mutationQueryObject);
break;
}
}
return next();
})
.catch((error) => {
const err = {
log: `Error inside catch block of operationType === mutation of query, ${error}`,
status: 400,
message: {
err: "GraphQL query (mutation) Error: Check server log for more details.",
},
};
return next(err);
});
}
else {
/*
* Otherwise, the operation type is a query.
*/
// Combine fragments on prototype so we can access fragment values in cache.
const prototype = Object.keys(frags).length > 0
? (0, quellHelpers_1.updateProtoWithFragment)(proto, frags)
: proto;
// Create a list of the keys on prototype that will be passed to buildFromCache.
const prototypeKeys = Object.keys(prototype);
// Check the cache for the requested values.
// buildFromCache will modify the prototype to mark any values not found in the cache
// so that they may later be retrieved from the database.
// const cacheResponse: {
// data: ItemFromCacheType;
// cached?: boolean;
// } = await this.buildFromCache(prototype, prototypeKeys);
const cacheResponse = yield this.buildFromCache(prototype, prototypeKeys);
// Create query object containing the fields that were not found in the cache.
// This will be used to create a new GraphQL string.
const queryObject = (0, quellHelpers_1.createQueryObj)(prototype);
// If the cached response is incomplete, reformulate query,
// handoff query, join responses, and cache joined responses.
if (Object.keys(queryObject).length > 0) {
// Create a new query string that contains only the fields not found in the cache so that we can
// request only that information from the database.
console.log("QUERY OBJECT BEFORE createQueryStr:", JSON.stringify(queryObject, null, 2));
const newQueryString = (0, quellHelpers_1.createQueryStr)(queryObject, operationType);
console.log("NEW QUERY STRING:", newQueryString);
// Execute the query using the new query string.
(0, graphql_1.graphql)({ schema: this.schema, source: newQueryString })
.then((databaseResponseRaw) => __awaiter(this, void 0, void 0, function* () {
// The GraphQL must be parsed in order to join with it with the data retrieved from
// the cache before sending back to user.
const databaseResponse = JSON.parse(JSON.stringify(databaseResponseRaw));
console.log("DATABASE RESPONSE RAW:", JSON.stringify(databaseResponseRaw));
console.log("DATABASE RESPONSE PARSED:", JSON.stringify(databaseResponse));
// Check cache data
console.log("CACHE RESPONSE DATA:", JSON.stringify(cacheResponse.data));
// Determine cache hit status
let cacheHitStatus = "none";
// Check if cache has any data
let cacheHasData = false;
for (const key in cacheResponse.data) {
if (Object.keys(cacheResponse.data[key]).length > 0) {
cacheHasData = true;
break;
}
}
// Check if query needs any database data
const needsDatabaseData = Object.keys(queryObject).length > 0;
if (!needsDatabaseData) {
// Everything found in cache
cacheHitStatus = "full";
}
else if (cacheHasData) {
// Some data from cache, some from database
cacheHitStatus = "partial";
}
else {
// No data from cache, everything from database
cacheHitStatus = "none";
}
console.log("CACHE HAS DATA:", cacheHasData);
console.log("NEEDS DATABASE DATA:", needsDatabaseData);
console.log("CACHE HIT STATUS:", cacheHitStatus);
// Create merged response object to merge the data from the cache and the data from the database.
// If the cache response does not have data then just use the database response.
const mergedResponse = cacheHasData
? (0, quellHelpers_1.joinResponses)(cacheResponse.data, databaseResponse.data, prototype)
: databaseResponse;
console.log("=== ABOUT TO CACHE ===");
console.log("Merged Response:", JSON.stringify(mergedResponse, null, 2));
console.log("Prototype:", JSON.stringify(prototype, null, 2));
// CACHE THE COMPLETE MERGED RESPONSE
if (cacheHitStatus === "partial" || cacheHitStatus === "none") {
// Determine the correct data to cache based on the response structure
const dataToCache = cacheHitStatus === "none"
? mergedResponse.data // Database response has .data wrapper
: mergedResponse; // Joined response is already unwrapped
yield this.normalizeForCache(dataToCache, this.queryMap, prototype, "root");
}
console.log("=== FINISHED CACHING ===");
// const currName = "string it should not be again";
// const test = await this.normalizeForCache(
// mergedResponse.data as ResponseDataType,
// this.queryMap,
// prototype,
// currName
// );
// The response is given a cached key equal to false to indicate to the front end of the demo site that the
// information was *NOT* entirely found in the cache.
mergedResponse.cacheHit = cacheHitStatus;
res.locals.queryResponse = Object.assign({}, mergedResponse);
return next();
}))
.catch((error) => {
const err = {
log: `Error inside catch block of operationType === query of query, ${error}`,
status: 400,
message: {
err: `GraphQL query Error: Check server log for more details. Error: ${error}`,
},
};
return next(err);
});
}
else {
// If the query object is empty, there is nothing left to query and we can send the information from cache.
// The response is given a cached key equal to true to indicate to the front end of the demo site that the
// information was entirely found in the cache.
cacheResponse.cacheHit = "full";
res.locals.queryResponse = Object.assign({}, cacheResponse);
return next();
}
}
});
}
}
exports.QuellCache = QuellCache;