relay-runtime
Version:
A core runtime for building GraphQL-driven applications.
632 lines (597 loc) • 20.7 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 strict-local
* @format
* @oncall relay
*/
'use strict';
import type {ActorIdentifier} from '../multi-actor-environment/ActorIdentifier';
import type {
NormalizationLinkedField,
NormalizationLiveResolverField,
NormalizationModuleImport,
NormalizationNode,
NormalizationResolverField,
NormalizationScalarField,
NormalizationSelection,
} from '../util/NormalizationNode';
import type {DataID, Variables} from '../util/RelayRuntimeTypes';
import type {GetDataID} from './RelayResponseNormalizer';
import type {
LogFunction,
MissingFieldHandler,
MutableRecordSource,
NormalizationSelector,
OperationLoader,
RecordSource,
} from './RelayStoreTypes';
const RelayRecordSourceMutator = require('../mutations/RelayRecordSourceMutator');
const RelayRecordSourceProxy = require('../mutations/RelayRecordSourceProxy');
const getOperation = require('../util/getOperation');
const {isClientID} = require('./ClientID');
const cloneRelayHandleSourceField = require('./cloneRelayHandleSourceField');
const cloneRelayScalarHandleSourceField = require('./cloneRelayScalarHandleSourceField');
const {getLocalVariables} = require('./RelayConcreteVariables');
const RelayModernRecord = require('./RelayModernRecord');
const {EXISTENT, UNKNOWN} = require('./RelayRecordState');
const RelayStoreUtils = require('./RelayStoreUtils');
const {TYPE_SCHEMA_TYPE, generateTypeID} = require('./TypeID');
const invariant = require('invariant');
export type Availability = {
+status: 'available' | 'missing',
+mostRecentlyInvalidatedAt: ?number,
};
const {getModuleOperationKey, getStorageKey, getArgumentValues} =
RelayStoreUtils;
/**
* Synchronously check whether the records required to fulfill the given
* `selector` are present in `source`.
*
* If a field is missing, it uses the provided handlers to attempt to substitute
* data. The `target` will store all records that are modified because of a
* successful substitution.
*
* If all records are present, returns `true`, otherwise `false`.
*/
function check(
getSourceForActor: (actorIdentifier: ActorIdentifier) => RecordSource,
getTargetForActor: (actorIdentifier: ActorIdentifier) => MutableRecordSource,
defaultActorIdentifier: ActorIdentifier,
selector: NormalizationSelector,
handlers: $ReadOnlyArray<MissingFieldHandler>,
operationLoader: ?OperationLoader,
getDataID: GetDataID,
shouldProcessClientComponents: ?boolean,
log: ?LogFunction,
useExecTimeResolvers: ?boolean,
): Availability {
if (log != null) {
log({
name: 'store.datachecker.start',
selector,
});
}
const {dataID, node, variables} = selector;
const checker = new DataChecker(
getSourceForActor,
getTargetForActor,
defaultActorIdentifier,
variables,
handlers,
operationLoader,
getDataID,
shouldProcessClientComponents,
log,
useExecTimeResolvers,
);
const result = checker.check(node, dataID);
if (log != null) {
log({
name: 'store.datachecker.end',
selector,
});
}
return result;
}
/**
* @private
*/
class DataChecker {
_handlers: $ReadOnlyArray<MissingFieldHandler>;
_mostRecentlyInvalidatedAt: number | null;
_mutator: RelayRecordSourceMutator;
_operationLoader: OperationLoader | null;
_recordSourceProxy: RelayRecordSourceProxy;
_recordWasMissing: boolean;
_source: RecordSource;
_useExecTimeResolvers: boolean;
_variables: Variables;
_shouldProcessClientComponents: ?boolean;
+_getSourceForActor: (actorIdentifier: ActorIdentifier) => RecordSource;
+_getTargetForActor: (
actorIdentifier: ActorIdentifier,
) => MutableRecordSource;
+_getDataID: GetDataID;
+_mutatorRecordSourceProxyCache: Map<
ActorIdentifier,
[RelayRecordSourceMutator, RelayRecordSourceProxy],
>;
_log: ?LogFunction;
constructor(
getSourceForActor: (actorIdentifier: ActorIdentifier) => RecordSource,
getTargetForActor: (
actorIdentifier: ActorIdentifier,
) => MutableRecordSource,
defaultActorIdentifier: ActorIdentifier,
variables: Variables,
handlers: $ReadOnlyArray<MissingFieldHandler>,
operationLoader: ?OperationLoader,
getDataID: GetDataID,
shouldProcessClientComponents: ?boolean,
log: ?LogFunction,
useExecTimeResolvers: ?boolean,
) {
this._getSourceForActor = getSourceForActor;
this._getTargetForActor = getTargetForActor;
this._getDataID = getDataID;
this._source = getSourceForActor(defaultActorIdentifier);
this._mutatorRecordSourceProxyCache = new Map();
const [mutator, recordSourceProxy] = this._getMutatorAndRecordProxyForActor(
defaultActorIdentifier,
);
this._useExecTimeResolvers = useExecTimeResolvers ?? false;
this._mostRecentlyInvalidatedAt = null;
this._handlers = handlers;
this._mutator = mutator;
this._operationLoader = operationLoader ?? null;
this._recordSourceProxy = recordSourceProxy;
this._recordWasMissing = false;
this._variables = variables;
this._shouldProcessClientComponents = shouldProcessClientComponents;
this._log = log;
}
_getMutatorAndRecordProxyForActor(
actorIdentifier: ActorIdentifier,
): [RelayRecordSourceMutator, RelayRecordSourceProxy] {
let tuple = this._mutatorRecordSourceProxyCache.get(actorIdentifier);
if (tuple == null) {
const target = this._getTargetForActor(actorIdentifier);
const mutator = new RelayRecordSourceMutator(
this._getSourceForActor(actorIdentifier),
target,
);
const recordSourceProxy = new RelayRecordSourceProxy(
mutator,
this._getDataID,
undefined,
this._handlers,
this._log,
);
tuple = [mutator, recordSourceProxy];
this._mutatorRecordSourceProxyCache.set(actorIdentifier, tuple);
}
return tuple;
}
check(node: NormalizationNode, dataID: DataID): Availability {
this._assignClientAbstractTypes(node);
this._traverse(node, dataID);
return this._recordWasMissing === true
? {
status: 'missing',
mostRecentlyInvalidatedAt: this._mostRecentlyInvalidatedAt,
}
: {
status: 'available',
mostRecentlyInvalidatedAt: this._mostRecentlyInvalidatedAt,
};
}
_getVariableValue(name: string): mixed {
invariant(
this._variables.hasOwnProperty(name),
'RelayAsyncLoader(): Undefined variable `%s`.',
name,
);
return this._variables[name];
}
_handleMissing(): void {
this._recordWasMissing = true;
}
_handleMissingScalarField(
field: NormalizationScalarField,
dataID: DataID,
): mixed {
if (field.name === 'id' && field.alias == null && isClientID(dataID)) {
return undefined;
}
const args =
field.args != undefined
? getArgumentValues(field.args, this._variables)
: {};
for (const handler of this._handlers) {
if (handler.kind === 'scalar') {
const newValue = handler.handle(
field,
this._recordSourceProxy.get(dataID),
args,
this._recordSourceProxy,
);
if (newValue !== undefined) {
return newValue;
}
}
}
this._handleMissing();
}
_handleMissingLinkField(
field: NormalizationLinkedField,
dataID: DataID,
): ?DataID {
const args =
field.args != undefined
? getArgumentValues(field.args, this._variables)
: {};
for (const handler of this._handlers) {
if (handler.kind === 'linked') {
const newValue = handler.handle(
field,
this._recordSourceProxy.get(dataID),
args,
this._recordSourceProxy,
);
if (
newValue !== undefined &&
(newValue === null || this._mutator.getStatus(newValue) === EXISTENT)
) {
return newValue;
}
}
}
this._handleMissing();
}
_handleMissingPluralLinkField(
field: NormalizationLinkedField,
dataID: DataID,
): ?Array<?DataID> {
const args =
field.args != undefined
? getArgumentValues(field.args, this._variables)
: {};
for (const handler of this._handlers) {
if (handler.kind === 'pluralLinked') {
const newValue = handler.handle(
field,
this._recordSourceProxy.get(dataID),
args,
this._recordSourceProxy,
);
if (newValue != null) {
const allItemsKnown = newValue.every(
linkedID =>
linkedID != null &&
this._mutator.getStatus(linkedID) === EXISTENT,
);
if (allItemsKnown) {
return newValue;
}
} else if (newValue === null) {
return null;
}
}
}
this._handleMissing();
}
_traverse(node: NormalizationNode, dataID: DataID): void {
const status = this._mutator.getStatus(dataID);
if (status === UNKNOWN) {
this._handleMissing();
}
if (status === EXISTENT) {
const record = this._source.get(dataID);
const invalidatedAt = RelayModernRecord.getInvalidationEpoch(record);
if (invalidatedAt != null) {
this._mostRecentlyInvalidatedAt =
this._mostRecentlyInvalidatedAt != null
? Math.max(this._mostRecentlyInvalidatedAt, invalidatedAt)
: invalidatedAt;
}
this._traverseSelections(node.selections, dataID);
}
}
_traverseSelections(
selections: $ReadOnlyArray<NormalizationSelection>,
dataID: DataID,
): void {
selections.forEach(selection => {
switch (selection.kind) {
case 'ScalarField':
this._checkScalar(selection, dataID);
break;
case 'LinkedField':
if (selection.plural) {
this._checkPluralLink(selection, dataID);
} else {
this._checkLink(selection, dataID);
}
break;
case 'ActorChange':
this._checkActorChange(selection.linkedField, dataID);
break;
case 'Condition':
const conditionValue = Boolean(
this._getVariableValue(selection.condition),
);
if (conditionValue === selection.passingValue) {
this._traverseSelections(selection.selections, dataID);
}
break;
case 'InlineFragment': {
const {abstractKey} = selection;
if (abstractKey == null) {
// concrete type refinement: only check data if the type exactly matches
const typeName = this._mutator.getType(dataID);
if (typeName === selection.type) {
this._traverseSelections(selection.selections, dataID);
}
} else {
// Abstract refinement: check data depending on whether the type
// conforms to the interface/union or not:
// - Type known to _not_ implement the interface: don't check the selections.
// - Type is known _to_ implement the interface: check selections.
// - Unknown whether the type implements the interface: don't check the selections
// and treat the data as missing; we do this because the Relay Compiler
// guarantees that the type discriminator will always be fetched.
const recordType = this._mutator.getType(dataID);
invariant(
recordType != null,
'DataChecker: Expected record `%s` to have a known type',
dataID,
);
const typeID = generateTypeID(recordType);
const implementsInterface = this._mutator.getValue(
typeID,
abstractKey,
);
if (implementsInterface === true) {
this._traverseSelections(selection.selections, dataID);
} else if (implementsInterface == null) {
// unsure if the type implements the interface: data is
// missing so don't bother reading the fragment
this._handleMissing();
} // else false: known to not implement the interface
}
break;
}
case 'LinkedHandle': {
// Handles have no selections themselves; traverse the original field
// where the handle was set-up instead.
const handleField = cloneRelayHandleSourceField(
selection,
selections,
this._variables,
);
if (handleField.plural) {
this._checkPluralLink(handleField, dataID);
} else {
this._checkLink(handleField, dataID);
}
break;
}
case 'ScalarHandle': {
const handleField = cloneRelayScalarHandleSourceField(
selection,
selections,
this._variables,
);
this._checkScalar(handleField, dataID);
break;
}
case 'ModuleImport':
this._checkModuleImport(selection, dataID);
break;
case 'Defer':
case 'Stream':
this._traverseSelections(selection.selections, dataID);
break;
case 'FragmentSpread':
const prevVariables = this._variables;
this._variables = getLocalVariables(
this._variables,
selection.fragment.argumentDefinitions,
selection.args,
);
this._traverseSelections(selection.fragment.selections, dataID);
this._variables = prevVariables;
break;
case 'ClientExtension':
const recordWasMissing = this._recordWasMissing;
this._traverseSelections(selection.selections, dataID);
this._recordWasMissing = recordWasMissing;
break;
case 'TypeDiscriminator':
const {abstractKey} = selection;
const recordType = this._mutator.getType(dataID);
invariant(
recordType != null,
'DataChecker: Expected record `%s` to have a known type',
dataID,
);
const typeID = generateTypeID(recordType);
const implementsInterface = this._mutator.getValue(
typeID,
abstractKey,
);
if (implementsInterface == null) {
// unsure if the type implements the interface: data is
// missing
this._handleMissing();
} // else: if it does or doesn't implement, we don't need to check or skip anything else
break;
case 'ClientComponent':
if (this._shouldProcessClientComponents === false) {
break;
}
this._traverseSelections(selection.fragment.selections, dataID);
break;
case 'RelayResolver':
case 'RelayLiveResolver':
if (!this._useExecTimeResolvers) {
this._checkResolver(selection, dataID);
}
break;
case 'ClientEdgeToClientObject':
if (!this._useExecTimeResolvers) {
this._checkResolver(selection.backingField, dataID);
}
break;
default:
(selection: empty);
invariant(
false,
'RelayAsyncLoader(): Unexpected ast kind `%s`.',
selection.kind,
);
}
});
}
_checkResolver(
resolver: NormalizationResolverField | NormalizationLiveResolverField,
dataID: DataID,
) {
if (resolver.fragment) {
this._traverseSelections([resolver.fragment], dataID);
}
}
_checkModuleImport(
moduleImport: NormalizationModuleImport,
dataID: DataID,
): void {
const operationLoader = this._operationLoader;
invariant(
operationLoader !== null,
'DataChecker: Expected an operationLoader to be configured when using `@module`.',
);
const operationKey = getModuleOperationKey(moduleImport.documentName);
const operationReference = this._mutator.getValue(dataID, operationKey);
if (operationReference == null) {
if (operationReference === undefined) {
this._handleMissing();
}
return;
}
const normalizationRootNode = operationLoader.get(operationReference);
if (normalizationRootNode != null) {
const operation = getOperation(normalizationRootNode);
const prevVariables = this._variables;
this._variables = getLocalVariables(
this._variables,
operation.argumentDefinitions,
moduleImport.args,
);
this._traverse(operation, dataID);
this._variables = prevVariables;
} else {
// If the fragment is not available, we assume that the data cannot have been
// processed yet and must therefore be missing.
this._handleMissing();
}
}
_checkScalar(field: NormalizationScalarField, dataID: DataID): void {
const storageKey = getStorageKey(field, this._variables);
let fieldValue = this._mutator.getValue(dataID, storageKey);
if (fieldValue === undefined) {
fieldValue = this._handleMissingScalarField(field, dataID);
if (fieldValue !== undefined) {
this._mutator.setValue(dataID, storageKey, fieldValue);
}
}
}
_checkLink(field: NormalizationLinkedField, dataID: DataID): void {
const storageKey = getStorageKey(field, this._variables);
let linkedID = this._mutator.getLinkedRecordID(dataID, storageKey);
if (linkedID === undefined) {
linkedID = this._handleMissingLinkField(field, dataID);
if (linkedID != null) {
this._mutator.setLinkedRecordID(dataID, storageKey, linkedID);
} else if (linkedID === null) {
this._mutator.setValue(dataID, storageKey, null);
}
}
if (linkedID != null) {
this._traverse(field, linkedID);
}
}
_checkPluralLink(field: NormalizationLinkedField, dataID: DataID): void {
const storageKey = getStorageKey(field, this._variables);
let linkedIDs = this._mutator.getLinkedRecordIDs(dataID, storageKey);
if (linkedIDs === undefined) {
linkedIDs = this._handleMissingPluralLinkField(field, dataID);
if (linkedIDs != null) {
this._mutator.setLinkedRecordIDs(dataID, storageKey, linkedIDs);
} else if (linkedIDs === null) {
this._mutator.setValue(dataID, storageKey, null);
}
}
if (linkedIDs) {
linkedIDs.forEach(linkedID => {
if (linkedID != null) {
this._traverse(field, linkedID);
}
});
}
}
_checkActorChange(field: NormalizationLinkedField, dataID: DataID): void {
const storageKey = getStorageKey(field, this._variables);
const record = this._source.get(dataID);
const tuple =
record != null
? RelayModernRecord.getActorLinkedRecordID(record, storageKey)
: record;
if (tuple == null) {
if (tuple === undefined) {
this._handleMissing();
}
} else {
const [actorIdentifier, linkedID] = tuple;
const prevSource = this._source;
const prevMutator = this._mutator;
const prevRecordSourceProxy = this._recordSourceProxy;
const [mutator, recordSourceProxy] =
this._getMutatorAndRecordProxyForActor(actorIdentifier);
this._source = this._getSourceForActor(actorIdentifier);
this._mutator = mutator;
this._recordSourceProxy = recordSourceProxy;
this._assignClientAbstractTypes(field);
this._traverse(field, linkedID);
this._source = prevSource;
this._mutator = prevMutator;
this._recordSourceProxy = prevRecordSourceProxy;
}
}
// For abstract types defined in the client schema extension, we won't be
// getting `__is<AbstractType>` hints from the server. To handle this, the
// compiler attaches additional metadata on the normalization artifact,
// which we need to record into the store.
_assignClientAbstractTypes(node: NormalizationNode) {
const {clientAbstractTypes} = node;
if (clientAbstractTypes != null) {
for (const abstractType of Object.keys(clientAbstractTypes)) {
for (const concreteType of clientAbstractTypes[abstractType]) {
const typeID = generateTypeID(concreteType);
if (this._source.get(typeID) == null) {
this._mutator.create(typeID, TYPE_SCHEMA_TYPE);
}
if (this._mutator.getValue(typeID, abstractType) == null) {
this._mutator.setValue(typeID, abstractType, true);
}
}
}
}
}
}
module.exports = {
check,
};