[CORL-149] Persisted Queries (#2445)

* feat: enable persisted queries on the client

* fix: use `id` inside websocket message

* feat: initial server support for PQ

* feat: deeper server support

* feat: abstracted persisted query replacing logic
This commit is contained in:
Vinh
2019-08-15 21:03:32 +00:00
committed by Wyatt Johnson
parent 635e740fc0
commit 43b6a2cdcd
30 changed files with 1268 additions and 465 deletions
+41 -46
View File
@@ -1,9 +1,6 @@
import { CLIENT_ID_HEADER } from "coral-common/constants";
import { AppOptions } from "coral-server/app";
import {
graphqlBatchMiddleware,
graphqlMiddleware,
} from "coral-server/app/middleware/graphql";
import { graphqlMiddleware } from "coral-server/app/middleware/graphql";
import TenantContext, {
TenantContextOptions,
} from "coral-server/graph/tenant/context";
@@ -30,53 +27,51 @@ export const graphQLHandler = ({
metrics,
...options
}: GraphMiddlewareOptions): RequestHandler =>
graphqlBatchMiddleware(
graphqlMiddleware(
config,
async (req: Request) => {
if (!req.coral) {
throw new Error("coral was not set");
}
graphqlMiddleware(
config,
async (req: Request) => {
if (!req.coral) {
throw new Error("coral was not set");
}
// Pull out some useful properties from Coral.
const { id, now, tenant, cache, logger } = req.coral;
// Pull out some useful properties from Coral.
const { id, now, tenant, cache, logger } = req.coral;
if (!cache) {
throw new Error("cache was not set");
}
if (!cache) {
throw new Error("cache was not set");
}
if (!tenant) {
throw new Error("tenant was not set");
}
if (!tenant) {
throw new Error("tenant was not set");
}
// Create some new options to store the tenant context details inside.
const opts: TenantContextOptions = {
...options,
id,
now,
req,
config,
tenant,
logger,
};
// Create some new options to store the tenant context details inside.
const opts: TenantContextOptions = {
...options,
id,
now,
req,
config,
tenant,
logger,
};
// Add the user if there is one.
if (req.user) {
opts.user = req.user;
}
// Add the user if there is one.
if (req.user) {
opts.user = req.user;
}
// Add the clientID if there is one on the request.
const clientID = req.get(CLIENT_ID_HEADER);
if (clientID) {
// TODO: (wyattjoh) validate length
opts.clientID = clientID;
}
// Add the clientID if there is one on the request.
const clientID = req.get(CLIENT_ID_HEADER);
if (clientID) {
// TODO: (wyattjoh) validate length
opts.clientID = clientID;
}
return {
schema,
context: new TenantContext(opts),
};
},
metrics
)
return {
schema,
context: new TenantContext(opts),
};
},
metrics
);
+7 -4
View File
@@ -13,6 +13,7 @@ import { HTMLErrorHandler } from "coral-server/app/middleware/error";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import { createPassport } from "coral-server/app/middleware/passport";
import { Config } from "coral-server/config";
import { PersistedQueryCache } from "coral-server/models/queries";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { I18n } from "coral-server/services/i18n";
@@ -28,18 +29,20 @@ import { createRouter } from "./router";
export interface AppOptions {
config: Config;
disableClientRoutes: boolean;
i18n: I18n;
mailerQueue: MailerQueue;
scraperQueue: ScraperQueue;
metrics?: Metrics;
mongo: Db;
parent: Express;
persistedQueryCache: PersistedQueryCache;
persistedQueriesRequired: boolean;
pubsub: RedisPubSub;
redis: AugmentedRedis;
schema: GraphQLSchema;
scraperQueue: ScraperQueue;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
disableClientRoutes: boolean;
metrics?: Metrics;
pubsub: RedisPubSub;
}
/**
@@ -1,97 +0,0 @@
import { RequestHandler, Response } from "express";
import logger from "coral-server/logger";
import { Request } from "coral-server/types/express";
function wrapResponse(req: Request, res: Response) {
// If the request is not an array, or has no elements, we should skip it.
if (!Array.isArray(req.body) || req.body.length === 0) {
return res;
}
// If the request is an array, but it does not have an ID field, then we
// should skip it.
const needsUpgrade = Boolean(typeof req.body[0].id !== "undefined");
if (!needsUpgrade) {
return res;
}
// Grab all the existing ID's.
const ids: string[] = req.body.map(({ id }) => id);
// Save a reference to the old setHeader function.
const setHeader = res.setHeader.bind(res);
// Capture all the headers that are sent to this, in case we need to use it.
const setHeaders: Record<string, any> = {};
res.setHeader = (name: string, value: any) => {
setHeaders[name] = value;
return res;
};
// Save a reference to the old write function.
const write = res.write.bind(res);
// Create a flush function that will be used to flush the response to the
// underlying response.
const flush = (chunk: any, headers: Record<string, any> = setHeaders) => {
for (const name in headers) {
if (!headers.hasOwnProperty(name)) {
continue;
}
setHeader(name, headers[name]);
}
return write(chunk);
};
// Override the response writer to parse the response to determine if it needs
// to be rewritten.
res.write = (chunk: string) => {
try {
// If there is no response, forward it, or if we peek at the first
// character and it's not an array opening, then skip it.
if (chunk.length <= 0 || chunk[0] !== "[") {
return flush(chunk);
}
// Parse the responses, if it's not an array, then skip it.
const responses: object[] | any = JSON.parse(chunk);
if (!Array.isArray(responses) || responses.length === 0) {
return flush(chunk);
}
// If the length of responses do not equal the length of id's collected,
// then skip it.
if (responses.length !== ids.length) {
return flush(chunk);
}
// For each of the responses, zip up their id's into the objects, and
// string concat them together to ensure we get the right request.
const gqlResponse = responses.reduce((body: object[], payload, idx) => {
const id = ids[idx];
body.push({ id, payload });
return body;
}, []);
const response = JSON.stringify(gqlResponse);
return flush(response, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(response, "utf8").toString(),
});
} catch (err) {
logger.error({ err }, "could not parse chunk as JSON");
return flush(chunk);
}
};
return res;
}
export const graphqlBatchMiddleware = (
graphqlRequestHandler: RequestHandler
): RequestHandler => (req: Request, res, next) =>
graphqlRequestHandler(req, wrapResponse(req, res), next);
@@ -0,0 +1,85 @@
import { GraphQLExtension, GraphQLOptions } from "apollo-server-express";
import { Handler } from "express";
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
// TODO: when https://github.com/apollographql/apollo-server/pull/1907 is merged, update this import path
import {
ExpressGraphQLOptionsFunction,
graphqlExpress,
} from "apollo-server-express/dist/expressApollo";
import { Omit } from "coral-common/types";
import { Config } from "coral-server/config";
import {
ErrorWrappingExtension,
LoggerExtension,
MetricsExtension,
} from "coral-server/graph/common/extensions";
import { Metrics } from "coral-server/services/metrics";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
const NoIntrospection = (context: ValidationContext) => ({
Field(node: FieldDefinitionNode) {
if (node.name.value === "__schema" || node.name.value === "__type") {
context.reportError(
new GraphQLError(
"GraphQL introspection is not allowed in production, but the query contained __schema or __type.",
[node]
)
);
}
},
});
/**
* graphqlMiddleware wraps the GraphQL middleware server with some custom
* extension management.
*
* @param config application configuration
* @param requestOptions options to pass to the graphql server
*/
const graphqlMiddleware = (
config: Config,
requestOptions: ExpressGraphQLOptionsFunction,
metrics?: Metrics
): Handler => {
const extensions: Array<() => GraphQLExtension> = [
() => new ErrorWrappingExtension(),
() => new LoggerExtension(),
];
// Add the metrics extension if provided.
if (metrics) {
extensions.push(
() =>
// Pass the metrics to the extension so it can increment.
new MetricsExtension(metrics)
);
}
// Create a new baseOptions that will be merged into the new options.
const baseOptions: Omit<GraphQLOptions, "schema"> = {
// Disable the debug mode, as we already add in our logging function.
debug: false,
extensions,
};
if (config.get("env") === "production" && !config.get("enable_graphiql")) {
// Disable introspection in production.
baseOptions.validationRules = [NoIntrospection];
}
// Generate the actual middleware.
return graphqlExpress(async (req, res) => {
// Resolve the options for the GraphQL middleware.
const options = await requestOptions(req, res);
// Provide the options.
return {
...options,
...baseOptions,
};
});
};
export default graphqlMiddleware;
@@ -1,85 +1,4 @@
import { GraphQLExtension, GraphQLOptions } from "apollo-server-express";
import { Handler } from "express";
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
// TODO: when https://github.com/apollographql/apollo-server/pull/1907 is merged, update this import path
import {
ExpressGraphQLOptionsFunction,
graphqlExpress,
} from "apollo-server-express/dist/expressApollo";
import { Omit } from "coral-common/types";
import { Config } from "coral-server/config";
import {
ErrorWrappingExtension,
LoggerExtension,
MetricsExtension,
} from "coral-server/graph/common/extensions";
import { Metrics } from "coral-server/services/metrics";
export * from "./batch";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
const NoIntrospection = (context: ValidationContext) => ({
Field(node: FieldDefinitionNode) {
if (node.name.value === "__schema" || node.name.value === "__type") {
context.reportError(
new GraphQLError(
"GraphQL introspection is not allowed in production, but the query contained __schema or __type.",
[node]
)
);
}
},
});
/**
* graphqlMiddleware wraps the GraphQL middleware server with some custom
* extension management.
*
* @param config application configuration
* @param requestOptions options to pass to the graphql server
*/
export const graphqlMiddleware = (
config: Config,
requestOptions: ExpressGraphQLOptionsFunction,
metrics?: Metrics
): Handler => {
const extensions: Array<() => GraphQLExtension> = [
() => new ErrorWrappingExtension(),
() => new LoggerExtension(),
];
// Add the metrics extension if provided.
if (metrics) {
extensions.push(
() =>
// Pass the metrics to the extension so it can increment.
new MetricsExtension(metrics)
);
}
// Create a new baseOptions that will be merged into the new options.
const baseOptions: Omit<GraphQLOptions, "schema"> = {
// Disable the debug mode, as we already add in our logging function.
debug: false,
extensions,
};
if (config.get("env") === "production" && !config.get("enable_graphiql")) {
// Disable introspection in production.
baseOptions.validationRules = [NoIntrospection];
}
// Generate the actual middleware.
return graphqlExpress(async (req, res) => {
// Resolve the options for the GraphQL middleware.
const options = await requestOptions(req, res);
// Provide the options.
return {
...options,
...baseOptions,
};
});
};
export { default as graphqlMiddleware } from "./graphqlMiddleware";
export {
default as persistedQueryMiddleware,
} from "./persistedQueryMiddleware";
@@ -0,0 +1,53 @@
import { AppOptions } from "coral-server/app";
import { RawQueryNotAuthorized } from "coral-server/errors";
import { getPersistedQuery } from "coral-server/graph/tenant/persisted";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import { RequestHandler } from "coral-server/types/express";
type PersistedQueryMiddlewareOptions = Pick<
AppOptions,
"config" | "persistedQueryCache" | "persistedQueriesRequired"
>;
const persistedQueryMiddleware = (
options: PersistedQueryMiddlewareOptions
): RequestHandler => async (req, res, next) => {
try {
if (!req.coral) {
throw new Error("coral was not set");
}
// Pull out some useful properties from Coral.
const { tenant } = req.coral;
if (!tenant) {
throw new Error("tenant was not set");
}
// Handle the payload if it is a persisted query.
const body = req.method === "GET" ? req.query : req.body;
const query = await getPersistedQuery(options.persistedQueryCache, body);
if (!query) {
// Check to see if this is from an ADMIN token which is allowed to run
// un-persisted queries.
if (
options.persistedQueriesRequired &&
(!req.user || req.user.role !== GQLUSER_ROLE.ADMIN)
) {
throw new RawQueryNotAuthorized(
tenant.id,
req.user ? req.user.id : null
);
}
} else {
// The query was found for this operation, replace the query with the one
// provided.
body.query = query.query;
}
return next();
} catch (err) {
return next(err);
}
};
export default persistedQueryMiddleware;
+2
View File
@@ -9,6 +9,7 @@ import {
versionHandler,
} from "coral-server/app/handlers";
import { JSONErrorHandler } from "coral-server/app/middleware/error";
import { persistedQueryMiddleware } from "coral-server/app/middleware/graphql";
import { jsonMiddleware } from "coral-server/app/middleware/json";
import { errorLogger } from "coral-server/app/middleware/logging";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
@@ -60,6 +61,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
"/graphql",
authenticate(options.passport),
jsonMiddleware,
persistedQueryMiddleware(app),
graphQLHandler(app)
);
+25 -2
View File
@@ -50,6 +50,11 @@ export interface CoralErrorExtensions {
}
export interface CoralErrorContext {
/**
* tenantID is the ID of the tenant that this Error is associated with.
*/
tenantID?: string;
/**
* pub stores information that is used by the translation framework
* to provide context to the error being emitted to pass publicly. Sensitive
@@ -165,8 +170,8 @@ export class CoralError extends VError {
this.status = status;
// Capture the context for the error.
const { pub = {}, pvt = {} } = context;
this.context = { pub, pvt };
const { pub = {}, pvt = {}, tenantID } = context;
this.context = { tenantID, pub, pvt };
// Capture the extension parameters.
this.id = id;
@@ -679,3 +684,21 @@ export class PasswordIncorrect extends CoralError {
});
}
}
export class PersistedQueryNotFound extends CoralError {
constructor(id: string) {
super({
code: ERROR_CODES.PERSISTED_QUERY_NOT_FOUND,
context: { pub: { id } },
});
}
}
export class RawQueryNotAuthorized extends CoralError {
constructor(tenantID: string, userID: string | null) {
super({
code: ERROR_CODES.RAW_QUERY_NOT_AUTHORIZED,
context: { tenantID, pvt: { userID } },
});
}
}
+2
View File
@@ -51,4 +51,6 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
LIVE_UPDATES_DISABLED: "error-liveUpdatesDisabled",
PASSWORD_INCORRECT: "error-passwordIncorrect",
USERNAME_UPDATED_WITHIN_WINDOW: "error-usernameAlreadyUpdated",
PERSISTED_QUERY_NOT_FOUND: "error-persistedQueryNotFound",
RAW_QUERY_NOT_AUTHORIZED: "error-rawQueryNotAuthorized",
};
@@ -0,0 +1,2 @@
export * from "./loader";
export * from "./mapper";
@@ -0,0 +1,56 @@
import fs from "fs-extra";
import { parse } from "graphql";
import path from "path";
import { version } from "coral-common/version";
import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers";
import { PersistedQuery } from "coral-server/models/queries";
export function loadPersistedQueries() {
// Load the files in the persisted queries folder.
const dir = path.join(__dirname, "__generated__");
const files = fs.readdirSync(dir);
// Load each of the persisted queries.
const queries: PersistedQuery[] = [];
for (const filePath of files) {
// Parse the bundle name from the file.
const bundle = filePath.split(".")[0];
// Load the queries from this file.
const fullFilePath = path.join(dir, filePath);
const persistedQueries: Record<string, string> = fs.readJSONSync(
fullFilePath
);
// Go over each of the persisted queries and collect the ID and query to
// merge in.
for (const id in persistedQueries) {
if (!persistedQueries.hasOwnProperty(id)) {
continue;
}
// Grab the actual query out of the file.
const query = persistedQueries[id];
// Parse the file to extract the GraphQL Operation Name.
const { operation, operationName } = getOperationMetadata(parse(query));
if (!operation || !operationName) {
throw new Error(
`operation in ${fullFilePath} with ID ${id} does not have valid operation metadata`
);
}
queries.push({
id,
operation,
operationName,
query,
bundle,
version,
});
}
}
return queries;
}
@@ -0,0 +1,41 @@
import { PersistedQueryNotFound } from "coral-server/errors";
import { PersistedQuery } from "coral-server/models/queries";
interface Payload {
id?: string;
query?: string;
}
interface Cache {
get(id: string): Promise<PersistedQuery | null>;
}
/**
* getPersistedQuery will try to get the persisted query referenced by the
* payload and return it if one exists. If a persisted query is referenced, but
* non is available, it will throw an error.
*
* @param cache the cache to pull the query from
* @param payload the payload that references the query that should be read
*/
export async function getPersistedQuery(
cache: Cache,
payload?: Readonly<Payload>
) {
if (
!payload ||
!payload.id ||
// Persisted queries can either have a query set to `PERSISTED_QUERY` or is
// empty.
!(payload.query === "PERSISTED_QUERY" || payload.query === "")
) {
return null;
}
const query = await cache.get(payload.id);
if (!query) {
throw new PersistedQueryNotFound(payload.id);
}
return query;
}
@@ -9,6 +9,7 @@ import http, { IncomingMessage } from "http";
import {
ConnectionContext,
ExecutionParams,
OperationMessage,
OperationMessagePayload,
SubscriptionServer,
} from "subscriptions-transport-ws";
@@ -25,6 +26,7 @@ import {
CoralError,
InternalError,
LiveUpdatesDisabled,
RawQueryNotAuthorized,
TenantNotFoundError,
} from "coral-server/errors";
import {
@@ -33,6 +35,8 @@ import {
logQuery,
} from "coral-server/graph/common/extensions";
import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers";
import { getPersistedQuery } from "coral-server/graph/tenant/persisted";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { userIsStaff } from "coral-server/models/user/helpers";
import { extractTokenFromRequest } from "coral-server/services/jwt";
@@ -205,13 +209,41 @@ export function formatResponse({ metrics }: FormatResponseOptions) {
};
}
export type OnOperationOptions = FormatResponseOptions;
export type OnOperationOptions = FormatResponseOptions &
Pick<AppOptions, "persistedQueryCache" | "persistedQueriesRequired">;
export function onOperation(options: OnOperationOptions) {
return (message: any, params: ExecutionParams<TenantContext>) => {
return async (
message: OperationMessage,
params: ExecutionParams<TenantContext>
) => {
// Attach the response formatter.
params.formatResponse = formatResponse(options);
// Handle the payload if it is a persisted query.
const query = await getPersistedQuery(
options.persistedQueryCache,
message.payload
);
if (!query) {
// Check to see if this is from an ADMIN token which is allowed to run
// un-persisted queries.
if (
options.persistedQueriesRequired &&
(!params.context.user ||
params.context.user.role !== GQLUSER_ROLE.ADMIN)
) {
throw new RawQueryNotAuthorized(
params.context.tenant.id,
params.context.user ? params.context.user.id : null
);
}
} else {
// The query was found for this operation, replace the query with the one
// provided.
params.query = query.query;
}
return params;
};
}
+19
View File
@@ -19,6 +19,7 @@ import { createPubSubClient } from "coral-server/graph/common/subscriptions/pubs
import getTenantSchema from "coral-server/graph/tenant/schema";
import { createSubscriptionServer } from "coral-server/graph/tenant/subscriptions/server";
import logger from "coral-server/logger";
import { PersistedQueryCache } from "coral-server/models/queries";
import { createQueue, TaskQueue } from "coral-server/queue";
import { I18n } from "coral-server/services/i18n";
import { createJWTSigningConfig } from "coral-server/services/jwt";
@@ -260,6 +261,20 @@ class Server {
// Webpack Dev Server.
const disableClientRoutes = this.config.get("disable_client_routes");
// Load and upsert the persisted queries.
const persistedQueryCache = new PersistedQueryCache({ mongo: this.mongo });
// Prime the queries in the database.
await persistedQueryCache.prime();
logger.info(
{ queries: persistedQueryCache.size },
"loaded persisted queries"
);
if (persistedQueryCache.size === 0) {
logger.warn("no persisted queries loaded, did you run `npm run build`?");
}
const options: AppOptions = {
parent,
pubsub: this.pubsub,
@@ -273,6 +288,10 @@ class Server {
mailerQueue: this.tasks.mailer,
scraperQueue: this.tasks.scraper,
disableClientRoutes,
persistedQueryCache,
persistedQueriesRequired:
this.config.get("env") === "production" &&
!this.config.get("enable_graphiql"),
};
// Only enable the metrics server if concurrency is set to 1.
+3 -1
View File
@@ -54,4 +54,6 @@ error-rateLimitExceeded = Rate limit exceeded.
error-inviteTokenExpired = Invite link has expired.
error-inviteRequiresEmailAddresses = Please add an email address to send invitations.
error-passwordIncorrect = Password provided was incorrect.
error-usernameAlreadyUpdated = You may only change your username once every { framework-timeago-time }.
error-usernameAlreadyUpdated = You may only change your username once every { framework-timeago-time }.
error-persistedQueryNotFound = The persisted query with ID { $id } was not found.
error-rawQueryNotAuthorized = You are not authorized to execute this query.
+112
View File
@@ -0,0 +1,112 @@
import DataLoader from "dataloader";
import LRU from "lru-cache";
import { Db } from "mongodb";
import { loadPersistedQueries } from "coral-server/graph/tenant/persisted";
import logger from "coral-server/logger";
import { getQueries, PersistedQuery, primeQueries } from "./queries";
interface PersistedQueryCacheOptions {
mongo: Db;
}
/**
* PersistedQueryCache abstracts the persisted query management.
*/
export class PersistedQueryCache {
private mongo: Db;
private queries: Map<string, PersistedQuery>;
private cache: LRU<string, PersistedQuery>;
private loader: DataLoader<string, PersistedQuery | null>;
constructor(options: PersistedQueryCacheOptions) {
const queries = loadPersistedQueries();
this.mongo = options.mongo;
this.loader = new DataLoader(
(ids: string[]) => getQueries(this.mongo, ids),
{
// Turn off caching as we're using the LRU cache here instead.
cache: false,
}
);
this.queries = new Map();
this.cache = new LRU({
// We'll only retain the amount of queries we have right now so we could
// possibly hold the previous version in memory if need be. Ideally, we'll
// always have the keys we need in memory.
max: queries.length,
dispose: (id, query) => {
logger.warn(
{ queryID: id, queryVersion: query.version },
"cache full, dropping query from cache"
);
},
});
// Insert all the queries into the local query cache.
for (const query of queries) {
this.queries.set(query.id, query);
}
}
public get size() {
return this.queries.size + this.cache.length;
}
/**
* prime will load the local queries into the database so every time that the
* server starts, the queries will be available to other instances.
*/
public async prime() {
if (this.queries.size === 0) {
return;
}
const queries: PersistedQuery[] = [];
for (const query of this.queries.values()) {
queries.push(query);
}
logger.debug({ queries: queries.length }, "priming queries");
await primeQueries(this.mongo, queries);
}
/**
* get will retrieve a given PersistedQuery by ID.
*
* @param id the ID of the persisted query to load
*/
public async get(id: string) {
// Try to get the query from the local query cache.
let query: PersistedQuery | null | undefined = this.queries.get(id);
if (query) {
return query;
}
// Try to get the query from the remote cache.
query = this.cache.get(id);
if (query) {
return query;
}
// Try to get the query from the loader.
query = await this.loader.load(id);
if (query) {
logger.warn(
{ queryID: id, queryVersion: query.version },
"query did not exist in cache, retrieved from MongoDB"
);
// Cache this query in the memory cache.
this.cache.set(query.id, query);
return query;
}
logger.warn({ queryID: id }, "query did not exist in cache or MongoDB");
return null;
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./cache";
export * from "./queries";
+48
View File
@@ -0,0 +1,48 @@
import { Db } from "mongodb";
import { createIndexFactory } from "../helpers/indexing";
function collection(mongo: Db) {
return mongo.collection<Readonly<PersistedQuery>>("queries");
}
export interface PersistedQuery {
id: string;
operation: string;
operationName: string;
query: string;
bundle: string;
version: string;
}
export async function createQueriesIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ id: 1 }, { unique: true });
}
export async function primeQueries(mongo: Db, queries: PersistedQuery[]) {
// Setup persisting these queries.
const bulk = collection(mongo).initializeUnorderedBulkOp({});
// Upsert each query.
for (const query of queries) {
const { id } = query;
// Add to the bulk operation for MongoDB.
bulk
.find({ id })
.upsert()
.replaceOne(query);
}
// Execute the bulk operations.
await bulk.execute();
}
export async function getQueries(mongo: Db, ids: string[]) {
const cursor = await collection(mongo).find({ id: { $in: ids } });
const queries = await cursor.toArray();
return ids.map(id => queries.find(query => query.id === id) || null);
}
@@ -5,6 +5,7 @@ import { createCommentActionIndexes } from "coral-server/models/action/comment";
import { createCommentModerationActionIndexes } from "coral-server/models/action/moderation/comment";
import { createCommentIndexes } from "coral-server/models/comment";
import { createInviteIndexes } from "coral-server/models/invite";
import { createQueriesIndexes } from "coral-server/models/queries";
import {
createStoryCountIndexes,
createStoryIndexes,
@@ -23,6 +24,7 @@ const indexes: Array<[string, IndexCreationFunction]> = [
["stories", createStoryCountIndexes],
["commentActions", createCommentActionIndexes],
["commentModerationActions", createCommentModerationActionIndexes],
["queries", createQueriesIndexes],
];
/**