relay-runtime
Version:
A core runtime for building GraphQL-driven applications.
504 lines (453 loc) • 15 kB
Flow
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
* @oncall relay
*/
import type {ActorIdentifier} from '../multi-actor-environment/ActorIdentifier';
import type {PayloadData} from '../network/RelayNetworkTypes';
import type {
NormalizationField,
NormalizationLinkedField,
NormalizationNode,
} from '../util/NormalizationNode';
import type {DataID, Variables} from '../util/RelayRuntimeTypes';
import type {NormalizationOptions} from './RelayResponseNormalizer';
import type {GetDataID} from './RelayResponseNormalizer';
import type {
IncrementalDataPlaceholder,
NormalizationSelector,
} from './RelayStoreTypes';
const {
CLIENT_EXTENSION,
CONDITION,
DEFER,
FRAGMENT_SPREAD,
INLINE_FRAGMENT,
LINKED_FIELD,
LINKED_HANDLE,
SCALAR_FIELD,
SCALAR_HANDLE,
} = require('../util/RelayConcreteNode');
const {getLocalVariables} = require('./RelayConcreteVariables');
const {createNormalizationSelector} = require('./RelayModernSelector');
const {ROOT_TYPE, TYPENAME_KEY, getStorageKey} = require('./RelayStoreUtils');
const invariant = require('invariant');
const {generateClientID} = require('relay-runtime');
/**
* This module is an experiment to explore a proposal normalized response format for GraphQL.
* See the Quip document: Canonical Normalized Response Format (“GraphMode”) Proposal
*/
/**
* # TODO
*
* - [ ] Compute storage keys using method outlined in the proposal
* - [ ] Plural fields
* - [ ] Write a utility to populate the store using a GraphMode response.
*/
export type ScalarField = string | number | null;
export type LinkedField =
| {
__id: number,
}
| {
__ids: Array<number | null>,
};
export type ChunkField = ScalarField | Array<ScalarField> | LinkedField;
export type ChunkFields = {
[string]: ChunkField,
};
export type RecordChunk = {
$kind: 'Record',
$streamID: number,
__id: string,
__typename: string,
[string]: ChunkField,
};
export type ExtendChunk = {
$kind: 'Extend',
$streamID: number,
[string]: ChunkField,
};
export type CompleteChunk = {
$kind: 'Complete',
};
export type DataChunk = RecordChunk | ExtendChunk;
export type GraphModeChunk = DataChunk | CompleteChunk;
export type GraphModeResponse = Iterable<GraphModeChunk>;
export type TransformMetadata = {
duplicateFieldsAvoided: number,
};
/**
* Converts a JSON response (and Normalization AST) into a stream of GraphMode chunks
*
* The stream is modeled as a Generator in order to highlight the streaming
* nature of the response. Once a chunk is generated, it can be immediately flushed
* to the client.
*
* The response is traversed depth-first, meaning children are emitted before
* the parent. This allows parent objects to reference children using their
* `$streamID`.
*
* After each object is traversed, a chunk is emitted. The first time an object
* -- identified by its strong ID -- is encountered we emit a `Record`, and its
* `$streamID` is recorded. If that same object is encountered again later in
* the response, an `Extend` chunk is emitted, which includes any previously
* unsent fields. If no unsent fields are present in the second appearance of
* the new object, no chunk is emitted.
*
* ## State
*
* As we traverse we must maintain some state:
*
* - The next streamID
* - A mapping of cache keys to streamIDs
* - The set of fields which we've sent for each streamID. This allows us to
* avoid sending fields twice.
*/
export function normalizeResponse(
response: PayloadData,
selector: NormalizationSelector,
options: NormalizationOptions,
): GraphModeResponse {
const {node, variables, dataID} = selector;
const normalizer = new GraphModeNormalizer(variables, options);
return normalizer.normalizeResponse(node, dataID, response);
}
export function normalizeResponseWithMetadata(
response: PayloadData,
selector: NormalizationSelector,
options: NormalizationOptions,
): [Array<GraphModeChunk>, TransformMetadata] {
const {node, variables, dataID} = selector;
const normalizer = new GraphModeNormalizer(variables, options);
const chunks = Array.from(
normalizer.normalizeResponse(node, dataID, response),
);
return [chunks, {duplicateFieldsAvoided: normalizer.duplicateFieldsAvoided}];
}
export class GraphModeNormalizer {
_cacheKeyToStreamID: Map<string, number>;
_sentFields: Map<string, Set<string>>;
_nextStreamID: number;
_getDataID: GetDataID;
_variables: Variables;
_path: Array<string>;
_incrementalPlaceholders: Array<IncrementalDataPlaceholder>;
_actorIdentifier: ?ActorIdentifier;
duplicateFieldsAvoided: number;
constructor(variables: Variables, options: NormalizationOptions) {
this._actorIdentifier = options.actorIdentifier;
this._path = options.path ? [...options.path] : [];
this._getDataID = options.getDataID;
this._cacheKeyToStreamID = new Map();
this._sentFields = new Map();
this._nextStreamID = 0;
this._variables = variables;
this.duplicateFieldsAvoided = 0;
}
_getStreamID(): number {
return this._nextStreamID++;
}
_getSentFields(cacheKey: string): Set<string> {
const maybeSent = this._sentFields.get(cacheKey);
if (maybeSent != null) {
return maybeSent;
}
const sent = new Set<string>();
this._sentFields.set(cacheKey, sent);
return sent;
}
_getObjectType(data: PayloadData): string {
const typeName = (data: any)[TYPENAME_KEY];
invariant(
typeName != null,
'Expected a typename for record `%s`.',
JSON.stringify(data, null, 2),
);
return typeName;
}
// TODO: The GraphMode proposal outlines different approachs to derive keys. We
// can expriment with different approaches here.
_getStorageKey(selection: NormalizationField): string {
return getStorageKey(selection, this._variables);
}
_getVariableValue(name: string): mixed {
invariant(
this._variables.hasOwnProperty(name),
'Unexpected undefined variable `%s`.',
name,
);
return this._variables[name];
}
*normalizeResponse(
node: NormalizationNode,
dataID: DataID,
data: PayloadData,
): Generator<GraphModeChunk, void, void> {
const rootFields: ChunkFields = {};
yield* this._traverseSelections(node, data, rootFields, dataID, new Set());
const $streamID = this._getStreamID();
yield {
...rootFields,
$kind: 'Record',
$streamID,
__id: dataID,
__typename: ROOT_TYPE,
};
yield {
$kind: 'Complete',
};
}
*_flushFields(
cacheKey: string,
typename: string,
fields: ChunkFields,
): Generator<GraphModeChunk, number, void> {
const maybeStreamID = this._cacheKeyToStreamID.get(cacheKey);
const $streamID = maybeStreamID ?? this._getStreamID();
if (maybeStreamID == null) {
this._cacheKeyToStreamID.set(cacheKey, $streamID);
// TODO: We could mutate `fields` rather than constructing a new
// chunk object, but it's hard to convince Flow that we've
// constructed a valid Chunk, and perf is not important for this
// experimental transform
yield {
...fields,
$kind: 'Record',
__typename: typename,
__id: cacheKey,
$streamID,
};
} else if (Object.keys(fields).length > 0) {
yield {...fields, $kind: 'Extend', $streamID};
}
return $streamID;
}
*_traverseSelections(
node: NormalizationNode,
data: PayloadData,
parentFields: ChunkFields,
parentID: string,
sentFields: Set<string>,
): Generator<GraphModeChunk, void, void> {
const selections = node.selections;
for (const selection of selections) {
switch (selection.kind) {
case LINKED_FIELD: {
const responseKey = selection.alias ?? selection.name;
const fieldData: PayloadData = (data[responseKey]: any);
const storageKey = this._getStorageKey(selection);
this._path.push(responseKey);
const fieldValue = yield* this._traverseLinkedField(
selection.plural,
fieldData,
storageKey,
selection,
parentID,
);
this._path.pop();
// TODO: We could also opt to confirm that this matches the previously
// seen value.
if (sentFields.has(storageKey)) {
this.duplicateFieldsAvoided++;
break;
}
parentFields[storageKey] = fieldValue;
sentFields.add(storageKey);
break;
}
case SCALAR_FIELD: {
const responseKey = selection.alias ?? selection.name;
const storageKey = this._getStorageKey(selection);
// TODO: We could also opt to confirm that this matches the previously
// seen value.
if (sentFields.has(storageKey)) {
this.duplicateFieldsAvoided++;
break;
}
const fieldData: ChunkField = (data[responseKey]: any);
parentFields[storageKey] = fieldData;
sentFields.add(storageKey);
break;
}
case INLINE_FRAGMENT: {
const objType = this._getObjectType(data);
const {abstractKey} = selection;
if (abstractKey == null) {
if (objType !== selection.type) {
break;
}
} else if (!data.hasOwnProperty(abstractKey)) {
break;
}
yield* this._traverseSelections(
selection,
data,
parentFields,
parentID,
sentFields,
);
break;
}
case FRAGMENT_SPREAD: {
const prevVariables = this._variables;
this._variables = getLocalVariables(
this._variables,
selection.fragment.argumentDefinitions,
selection.args,
);
yield* this._traverseSelections(
selection.fragment,
data,
parentFields,
parentID,
sentFields,
);
this._variables = prevVariables;
break;
}
case CONDITION:
const conditionValue = Boolean(
this._getVariableValue(selection.condition),
);
if (conditionValue === selection.passingValue) {
yield* this._traverseSelections(
selection,
data,
parentFields,
parentID,
sentFields,
);
}
break;
case DEFER:
const isDeferred =
selection.if === null || this._getVariableValue(selection.if);
if (isDeferred === false) {
// If defer is disabled there will be no additional response chunk:
// normalize the data already present.
yield* this._traverseSelections(
selection,
data,
parentFields,
parentID,
sentFields,
);
} else {
// Otherwise data *for this selection* should not be present: enqueue
// metadata to process the subsequent response chunk.
this._incrementalPlaceholders.push({
kind: 'defer',
data,
label: selection.label,
path: [...this._path],
selector: createNormalizationSelector(
selection,
parentID,
this._variables,
),
typeName: this._getObjectType(data),
actorIdentifier: this._actorIdentifier,
});
}
break;
case CLIENT_EXTENSION:
// Since we are only expecting to handle server responses, we can skip
// over client extensions.
break;
case SCALAR_HANDLE:
case LINKED_HANDLE:
// Handles allow us to record information that will be needed to
// perform additional process when we insert data into the store. For
// example, connection edges need to be prepended/appended to the
// pre-existing values.
//
// GraphMode will eventually need some replacement for this, but it is
// not nessesary in order to measure things like response size, so we
// can ignore these for now.
break;
default:
throw new Error(`Unexpected selection type: ${selection.kind}`);
}
}
}
*_traverseLinkedField(
plural: boolean,
fieldData: PayloadData,
storageKey: string,
selection: NormalizationLinkedField,
parentID: string,
index?: number,
): Generator<GraphModeChunk, ChunkField, void> {
if (fieldData == null) {
return null;
}
if (plural) {
invariant(
Array.isArray(fieldData),
`Expected fieldData to be an array. Got ${JSON.stringify(fieldData)}`,
);
const fieldValue = [];
for (const [i, itemData] of fieldData.entries()) {
this._path.push(String(i));
const itemValue = yield* this._traverseLinkedField(
false,
itemData,
storageKey,
selection,
parentID,
i,
);
this._path.pop();
fieldValue.push(itemValue);
}
const ids = fieldValue.map(value => {
if (value == null) {
return null;
}
invariant(
typeof value.__id === 'number',
'Expected objects in a plural linked field to have an __id.',
);
return value.__id;
});
return {__ids: ids};
}
invariant(
typeof fieldData === 'object',
'Expected data for field `%s` to be an object.',
storageKey,
);
const objType = selection.concreteType ?? this._getObjectType(fieldData);
const nextID =
this._getDataID(fieldData, objType) ||
// Note: In RelayResponseNormalizer we try to access a cached
// version of the key before generating a new one. I'm not clear if
// that's a performance optimization (which would not be important
// here) or important for stable ids.
// TODO: The proposal does not yet specify how we handle objects
// without strong ids.
generateClientID(parentID, storageKey, index);
invariant(
typeof nextID === 'string',
'Expected id on field `%s` to be a string.',
storageKey,
);
const fields: ChunkFields = {};
// Yield any decendent record chunks, and mutatively populate direct fields.
yield* this._traverseSelections(
selection,
fieldData,
fields,
nextID,
this._getSentFields(nextID),
);
const $streamID = yield* this._flushFields(nextID, objType, fields);
return {__id: $streamID};
}
}