mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:33:06 +08:00
[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:
@@ -21,3 +21,4 @@ dist
|
||||
*.css.d.ts
|
||||
__generated__
|
||||
README.md.orig
|
||||
persisted-queries.json
|
||||
|
||||
+5
-5
@@ -27,7 +27,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run --silent generate:relay-stream", {
|
||||
executor: new CommandExecutor("npm run --silent generate:relay:stream", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
@@ -44,7 +44,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run generate:relay-account", {
|
||||
executor: new CommandExecutor("npm run generate:relay:account", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
@@ -61,7 +61,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run --silent generate:relay-admin", {
|
||||
executor: new CommandExecutor("npm run --silent generate:relay:admin", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
@@ -78,7 +78,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run --silent generate:relay-auth", {
|
||||
executor: new CommandExecutor("npm run --silent generate:relay:auth", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
@@ -95,7 +95,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run --silent generate:relay-install", {
|
||||
executor: new CommandExecutor("npm run --silent generate:relay:install", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
|
||||
Generated
+593
-190
File diff suppressed because it is too large
Load Diff
+14
-9
@@ -20,18 +20,21 @@
|
||||
],
|
||||
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production npm-run-all generate --parallel lint:client build:client build:server",
|
||||
"build": "NODE_ENV=production npm-run-all generate-persist --parallel lint:client build:client build:server",
|
||||
"build:development": "NODE_ENV=development npm-run-all generate --parallel lint:client build:client build:server",
|
||||
"build:client": "ts-node --transpile-only ./scripts/build.ts",
|
||||
"build:server": "gulp server",
|
||||
"doctoc": "doctoc --title='## Table of Contents' --github README.md",
|
||||
"generate": "npm-run-all --parallel generate:*",
|
||||
"generate": "npm-run-all generate:css-types generate:schema generate:relay",
|
||||
"generate-persist": "npm-run-all generate:css-types generate:schema generate:relay-persist",
|
||||
"generate:css-types": "tcm src/core/client/",
|
||||
"generate:relay-stream": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
|
||||
"generate:relay-account": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/account --schema tenant",
|
||||
"generate:relay-auth": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
|
||||
"generate:relay-install": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
|
||||
"generate:relay-admin": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
|
||||
"generate:relay": "npm-run-all --parallel generate:relay:*",
|
||||
"generate:relay-persist": "npm-run-all --parallel 'generate:relay:* -- --persist'",
|
||||
"generate:relay:stream": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
|
||||
"generate:relay:account": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/account --schema tenant",
|
||||
"generate:relay:auth": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
|
||||
"generate:relay:install": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
|
||||
"generate:relay:admin": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
|
||||
"generate:schema": "node ./scripts/generateSchemaTypes.js",
|
||||
"docz": "docz",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
@@ -55,7 +58,7 @@
|
||||
"@coralproject/bunyan-prettystream": "^0.1.4",
|
||||
"@types/archiver": "^3.0.0",
|
||||
"akismet-api": "^4.2.0",
|
||||
"apollo-server-express": "^2.1.0",
|
||||
"apollo-server-express": "^2.8.1",
|
||||
"archiver": "^3.0.3",
|
||||
"basic-auth": "^2.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -102,6 +105,7 @@
|
||||
"linkify-it": "^2.1.0",
|
||||
"linkifyjs": "^2.1.8",
|
||||
"lodash": "^4.17.10",
|
||||
"lru-cache": "^5.1.1",
|
||||
"luxon": "^1.12.0",
|
||||
"metascraper-author": "^3.11.8",
|
||||
"metascraper-date": "^3.11.4",
|
||||
@@ -126,7 +130,7 @@
|
||||
"prom-client": "^11.3.0",
|
||||
"proxy-agent": "^3.1.0",
|
||||
"querystringify": "^2.1.0",
|
||||
"react-relay-network-modern": "^3.0.4",
|
||||
"react-relay-network-modern": "^4.0.4",
|
||||
"source-map-support": "^0.5.12",
|
||||
"stack-utils": "^1.0.2",
|
||||
"striptags": "^3.1.1",
|
||||
@@ -184,6 +188,7 @@
|
||||
"@types/linkify-it": "^2.1.0",
|
||||
"@types/linkifyjs": "^2.1.1",
|
||||
"@types/lodash": "^4.14.118",
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/luxon": "^1.12.0",
|
||||
"@types/marked": "^0.6.0",
|
||||
"@types/mini-css-extract-plugin": "^0.2.0",
|
||||
|
||||
+26
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
import program from "commander";
|
||||
import spawn from "cross-spawn";
|
||||
import fs from "fs";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
|
||||
const config = JSON.parse(
|
||||
@@ -14,6 +14,7 @@ program
|
||||
.usage("--src ./src/core/client/stream --schema tenant")
|
||||
.option("--src <folder>", "Find gql recursively in this folder")
|
||||
.option("--schema <schema>", "Identifier of schema")
|
||||
.option("--persist", "Use persisted queries")
|
||||
.description("Compile relay gql data")
|
||||
.parse(process.argv);
|
||||
|
||||
@@ -57,8 +58,30 @@ const args = [
|
||||
`${program.src}/__generated__`,
|
||||
"--schema",
|
||||
config.projects[program.schema].schemaPath,
|
||||
// "--persist-output",
|
||||
// `${program.src}/persisted-queries.json`,
|
||||
];
|
||||
|
||||
// Set the persisted query path.
|
||||
const persist = program.persist
|
||||
? `${program.src}/persisted-queries.json`
|
||||
: null;
|
||||
if (persist) {
|
||||
args.push("--persist-output", persist);
|
||||
}
|
||||
|
||||
spawn.sync("relay-compiler", args, { stdio: "inherit" });
|
||||
|
||||
if (persist) {
|
||||
if (fs.existsSync(persist)) {
|
||||
// Create the new filename.
|
||||
const name = path.basename(program.src);
|
||||
const generated = "./src/core/server/graph/tenant/persisted/__generated__";
|
||||
|
||||
// Create the generated directory if it doesn't exist.
|
||||
fs.ensureDirSync(generated);
|
||||
|
||||
// Copy the file over to the destination directory.
|
||||
fs.copySync(persist, `${generated}/${name}.json`, {
|
||||
overwrite: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ import {
|
||||
Disposable,
|
||||
Variables,
|
||||
} from "react-relay-network-modern/es";
|
||||
import { SubscriptionClient } from "subscriptions-transport-ws";
|
||||
import {
|
||||
OperationOptions,
|
||||
SubscriptionClient,
|
||||
} from "subscriptions-transport-ws";
|
||||
|
||||
import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
|
||||
import { ERROR_CODES } from "coral-common/errors";
|
||||
|
||||
/**
|
||||
* SubscriptionRequest containts the subscription
|
||||
* SubscriptionRequest contains the subscription
|
||||
* request data that comes from Relay.
|
||||
*/
|
||||
export interface SubscriptionRequest {
|
||||
@@ -109,17 +112,29 @@ export default function createManagedSubscriptionClient(
|
||||
},
|
||||
});
|
||||
}
|
||||
const subscription = subscriptionClient
|
||||
.request({
|
||||
operationName: operation.name,
|
||||
query: operation.text!,
|
||||
variables,
|
||||
})
|
||||
.subscribe({
|
||||
next({ data }) {
|
||||
observer.onNext({ data });
|
||||
},
|
||||
});
|
||||
if (!operation.text && !operation.id) {
|
||||
throw Error("Neither subscription query nor id was provided.");
|
||||
}
|
||||
|
||||
const opts: OperationOptions = {
|
||||
operationName: operation.name,
|
||||
// subscriptions-transport-ws requires `query` to be set to an non-empty string.
|
||||
// With persisted queries we only have the id, so set this to
|
||||
// "PERSISTED_QUERY" to get around validation.
|
||||
query: operation.text || "PERSISTED_QUERY",
|
||||
variables,
|
||||
};
|
||||
|
||||
// Query is not available which means we can use the id from persisted queries.
|
||||
if (!operation.text) {
|
||||
opts.id = operation.id;
|
||||
}
|
||||
|
||||
const subscription = subscriptionClient.request(opts).subscribe({
|
||||
next({ data }) {
|
||||
observer.onNext({ data });
|
||||
},
|
||||
});
|
||||
request.unsubscribe = () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
authMiddleware,
|
||||
batchMiddleware,
|
||||
cacheMiddleware,
|
||||
RelayNetworkLayer,
|
||||
retryMiddleware,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
import clientIDMiddleware from "./clientIDMiddleware";
|
||||
import { ManagedSubscriptionClient } from "./createManagedSubscriptionClient";
|
||||
import customErrorMiddleware from "./customErrorMiddleware";
|
||||
import persistedQueriesGetMethodMiddleware from "./persistedQueriesGetMethodMiddleware";
|
||||
|
||||
export type TokenGetter = () => string;
|
||||
|
||||
@@ -51,11 +51,6 @@ export default function createNetwork(
|
||||
urlMiddleware({
|
||||
url: () => Promise.resolve(graphqlURL),
|
||||
}),
|
||||
batchMiddleware({
|
||||
batchUrl: (requestMap: any) => Promise.resolve(graphqlURL),
|
||||
batchTimeout: 0,
|
||||
allowMutations: true,
|
||||
}),
|
||||
retryMiddleware({
|
||||
fetchTimeout: 15000,
|
||||
retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100,
|
||||
@@ -66,6 +61,7 @@ export default function createNetwork(
|
||||
token: tokenGetter,
|
||||
}),
|
||||
clientIDMiddleware(clientID),
|
||||
persistedQueriesGetMethodMiddleware,
|
||||
],
|
||||
{ subscribeFn: createSubscriptionFunction(subscriptionClient) }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Middleware, RelayRequestAny } from "react-relay-network-modern/es";
|
||||
|
||||
import { modifyQuery } from "coral-framework/utils";
|
||||
|
||||
function hasMutations(req: RelayRequestAny): boolean {
|
||||
return req.isMutation();
|
||||
}
|
||||
|
||||
function queriesAreEmpty(req: RelayRequestAny): boolean {
|
||||
return req.getQueryString() === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* persistedQueriesGetMethodMiddleware will use the GET method instead of POST for
|
||||
* all request excluding mutations when persisted queries are used.
|
||||
* The request data will be encoded in base64url and set in the GET query string under
|
||||
* the variable "d=".
|
||||
*/
|
||||
const persistedQueriesGetMethodMiddleware: Middleware = next => async req => {
|
||||
if (queriesAreEmpty(req) && !hasMutations(req)) {
|
||||
// Pull the body out (serializing it) and delete it off of the original
|
||||
// fetch options.
|
||||
const body: Record<string, any> = JSON.parse(req.fetchOpts.body as string);
|
||||
delete req.fetchOpts.body;
|
||||
|
||||
// Reconfigure the fetch for GET.
|
||||
req.fetchOpts.method = "GET";
|
||||
|
||||
// Rebuild the query parameters for GET.
|
||||
const params: Record<string, string> = { query: "" };
|
||||
for (const key in body) {
|
||||
if (!body.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = body[key];
|
||||
params[key] = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Combine the new parameters onto the URL.
|
||||
req.fetchOpts.url = modifyQuery(req.fetchOpts.url as string, params);
|
||||
}
|
||||
return next(req);
|
||||
};
|
||||
|
||||
export default persistedQueriesGetMethodMiddleware;
|
||||
@@ -295,4 +295,17 @@ export enum ERROR_CODES {
|
||||
* someone now allowed when it is disabled on the tenant level.
|
||||
*/
|
||||
LIVE_UPDATES_DISABLED = "LIVE_UPDATES_DISABLED",
|
||||
|
||||
/**
|
||||
* PERSISTED_QUERY_NOT_FOUND is returned when a query is executed specifying a
|
||||
* persisted query that can not be found.
|
||||
*/
|
||||
PERSISTED_QUERY_NOT_FOUND = "PERSISTED_QUERY_NOT_FOUND",
|
||||
|
||||
/**
|
||||
* RAW_QUERY_NOT_AUTHORIZED is returned when a query is executed that is not a
|
||||
* persisted query when the server has configured such queries are required by
|
||||
* all non-admin users.
|
||||
*/
|
||||
RAW_QUERY_NOT_AUTHORIZED = "RAW_QUERY_NOT_AUTHORIZED",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./cache";
|
||||
export * from "./queries";
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ declare module "react-relay-network-modern/es" {
|
||||
|
||||
export type Requests = RelayRequest[];
|
||||
|
||||
export default class RelayRequestBatch {
|
||||
export class RelayRequestBatch {
|
||||
public fetchOpts: Partial<FetchOpts>;
|
||||
public requests: Requests;
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "ES2018",
|
||||
"module": "commonjs",
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
|
||||
Reference in New Issue
Block a user