@apollo/client
Version:
A fully-featured caching GraphQL client.
529 lines • 27.8 kB
JavaScript
import { __assign } from "tslib";
import { invariant, newInvariantError } from "../../utilities/globals/index.js";
import { equal } from "@wry/equality";
import { Trie } from "@wry/trie";
import { Kind } from "graphql";
import { getFragmentFromSelection, getDefaultValues, getOperationDefinition, getTypenameFromResult, makeReference, isField, resultKeyNameFromField, isReference, shouldInclude, cloneDeep, addTypenameToDocument, isNonEmptyArray, argumentsObjectFromField, canonicalStringify, } from "../../utilities/index.js";
import { isArray, makeProcessedFieldsMerger, fieldNameFromStoreName, storeValueIsStoreObject, extractFragmentContext, } from "./helpers.js";
import { normalizeReadFieldOptions } from "./policies.js";
// Since there are only four possible combinations of context.clientOnly and
// context.deferred values, we should need at most four "flavors" of any given
// WriteContext. To avoid creating multiple copies of the same context, we cache
// the contexts in the context.flavors Map (shared by all flavors) according to
// their clientOnly and deferred values (always in that order).
function getContextFlavor(context, clientOnly, deferred) {
var key = "".concat(clientOnly).concat(deferred);
var flavored = context.flavors.get(key);
if (!flavored) {
context.flavors.set(key, (flavored =
context.clientOnly === clientOnly && context.deferred === deferred ?
context
: __assign(__assign({}, context), { clientOnly: clientOnly, deferred: deferred })));
}
return flavored;
}
var StoreWriter = /** @class */ (function () {
function StoreWriter(cache, reader, fragments) {
this.cache = cache;
this.reader = reader;
this.fragments = fragments;
}
StoreWriter.prototype.writeToStore = function (store, _a) {
var _this = this;
var query = _a.query, result = _a.result, dataId = _a.dataId, variables = _a.variables, overwrite = _a.overwrite;
var operationDefinition = getOperationDefinition(query);
var merger = makeProcessedFieldsMerger();
variables = __assign(__assign({}, getDefaultValues(operationDefinition)), variables);
var context = __assign(__assign({ store: store, written: Object.create(null), merge: function (existing, incoming) {
return merger.merge(existing, incoming);
}, variables: variables, varString: canonicalStringify(variables) }, extractFragmentContext(query, this.fragments)), { overwrite: !!overwrite, incomingById: new Map(), clientOnly: false, deferred: false, flavors: new Map() });
var ref = this.processSelectionSet({
result: result || Object.create(null),
dataId: dataId,
selectionSet: operationDefinition.selectionSet,
mergeTree: { map: new Map() },
context: context,
});
if (!isReference(ref)) {
throw newInvariantError(11, result);
}
// So far, the store has not been modified, so now it's time to process
// context.incomingById and merge those incoming fields into context.store.
context.incomingById.forEach(function (_a, dataId) {
var storeObject = _a.storeObject, mergeTree = _a.mergeTree, fieldNodeSet = _a.fieldNodeSet;
var entityRef = makeReference(dataId);
if (mergeTree && mergeTree.map.size) {
var applied = _this.applyMerges(mergeTree, entityRef, storeObject, context);
if (isReference(applied)) {
// Assume References returned by applyMerges have already been merged
// into the store. See makeMergeObjectsFunction in policies.ts for an
// example of how this can happen.
return;
}
// Otherwise, applyMerges returned a StoreObject, whose fields we should
// merge into the store (see store.merge statement below).
storeObject = applied;
}
if (globalThis.__DEV__ !== false && !context.overwrite) {
var fieldsWithSelectionSets_1 = Object.create(null);
fieldNodeSet.forEach(function (field) {
if (field.selectionSet) {
fieldsWithSelectionSets_1[field.name.value] = true;
}
});
var hasSelectionSet_1 = function (storeFieldName) {
return fieldsWithSelectionSets_1[fieldNameFromStoreName(storeFieldName)] ===
true;
};
var hasMergeFunction_1 = function (storeFieldName) {
var childTree = mergeTree && mergeTree.map.get(storeFieldName);
return Boolean(childTree && childTree.info && childTree.info.merge);
};
Object.keys(storeObject).forEach(function (storeFieldName) {
// If a merge function was defined for this field, trust that it
// did the right thing about (not) clobbering data. If the field
// has no selection set, it's a scalar field, so it doesn't need
// a merge function (even if it's an object, like JSON data).
if (hasSelectionSet_1(storeFieldName) &&
!hasMergeFunction_1(storeFieldName)) {
warnAboutDataLoss(entityRef, storeObject, storeFieldName, context.store);
}
});
}
store.merge(dataId, storeObject);
});
// Any IDs written explicitly to the cache will be retained as
// reachable root IDs for garbage collection purposes. Although this
// logic includes root IDs like ROOT_QUERY and ROOT_MUTATION, their
// retainment counts are effectively ignored because cache.gc() always
// includes them in its root ID set.
store.retain(ref.__ref);
return ref;
};
StoreWriter.prototype.processSelectionSet = function (_a) {
var _this = this;
var dataId = _a.dataId, result = _a.result, selectionSet = _a.selectionSet, context = _a.context,
// This object allows processSelectionSet to report useful information
// to its callers without explicitly returning that information.
mergeTree = _a.mergeTree;
var policies = this.cache.policies;
// This variable will be repeatedly updated using context.merge to
// accumulate all fields that need to be written into the store.
var incoming = Object.create(null);
// If typename was not passed in, infer it. Note that typename is
// always passed in for tricky-to-infer cases such as "Query" for
// ROOT_QUERY.
var typename = (dataId && policies.rootTypenamesById[dataId]) ||
getTypenameFromResult(result, selectionSet, context.fragmentMap) ||
(dataId && context.store.get(dataId, "__typename"));
if ("string" === typeof typename) {
incoming.__typename = typename;
}
// This readField function will be passed as context.readField in the
// KeyFieldsContext object created within policies.identify (called below).
// In addition to reading from the existing context.store (thanks to the
// policies.readField(options, context) line at the very bottom), this
// version of readField can read from Reference objects that are currently
// pending in context.incomingById, which is important whenever keyFields
// need to be extracted from a child object that processSelectionSet has
// turned into a Reference.
var readField = function () {
var options = normalizeReadFieldOptions(arguments, incoming, context.variables);
if (isReference(options.from)) {
var info = context.incomingById.get(options.from.__ref);
if (info) {
var result_1 = policies.readField(__assign(__assign({}, options), { from: info.storeObject }), context);
if (result_1 !== void 0) {
return result_1;
}
}
}
return policies.readField(options, context);
};
var fieldNodeSet = new Set();
this.flattenFields(selectionSet, result,
// This WriteContext will be the default context value for fields returned
// by the flattenFields method, but some fields may be assigned a modified
// context, depending on the presence of @client and other directives.
context, typename).forEach(function (context, field) {
var _a;
var resultFieldKey = resultKeyNameFromField(field);
var value = result[resultFieldKey];
fieldNodeSet.add(field);
if (value !== void 0) {
var storeFieldName = policies.getStoreFieldName({
typename: typename,
fieldName: field.name.value,
field: field,
variables: context.variables,
});
var childTree = getChildMergeTree(mergeTree, storeFieldName);
var incomingValue = _this.processFieldValue(value, field,
// Reset context.clientOnly and context.deferred to their default
// values before processing nested selection sets.
field.selectionSet ?
getContextFlavor(context, false, false)
: context, childTree);
// To determine if this field holds a child object with a merge function
// defined in its type policy (see PR #7070), we need to figure out the
// child object's __typename.
var childTypename = void 0;
// The field's value can be an object that has a __typename only if the
// field has a selection set. Otherwise incomingValue is scalar.
if (field.selectionSet &&
(isReference(incomingValue) || storeValueIsStoreObject(incomingValue))) {
childTypename = readField("__typename", incomingValue);
}
var merge = policies.getMergeFunction(typename, field.name.value, childTypename);
if (merge) {
childTree.info = {
// TODO Check compatibility against any existing childTree.field?
field: field,
typename: typename,
merge: merge,
};
}
else {
maybeRecycleChildMergeTree(mergeTree, storeFieldName);
}
incoming = context.merge(incoming, (_a = {},
_a[storeFieldName] = incomingValue,
_a));
}
else if (globalThis.__DEV__ !== false &&
!context.clientOnly &&
!context.deferred &&
!addTypenameToDocument.added(field) &&
// If the field has a read function, it may be a synthetic field or
// provide a default value, so its absence from the written data should
// not be cause for alarm.
!policies.getReadFunction(typename, field.name.value)) {
globalThis.__DEV__ !== false && invariant.error(12, resultKeyNameFromField(field), result);
}
});
// Identify the result object, even if dataId was already provided,
// since we always need keyObject below.
try {
var _b = policies.identify(result, {
typename: typename,
selectionSet: selectionSet,
fragmentMap: context.fragmentMap,
storeObject: incoming,
readField: readField,
}), id = _b[0], keyObject = _b[1];
// If dataId was not provided, fall back to the id just generated by
// policies.identify.
dataId = dataId || id;
// Write any key fields that were used during identification, even if
// they were not mentioned in the original query.
if (keyObject) {
// TODO Reverse the order of the arguments?
incoming = context.merge(incoming, keyObject);
}
}
catch (e) {
// If dataId was provided, tolerate failure of policies.identify.
if (!dataId)
throw e;
}
if ("string" === typeof dataId) {
var dataRef = makeReference(dataId);
// Avoid processing the same entity object using the same selection
// set more than once. We use an array instead of a Set since most
// entity IDs will be written using only one selection set, so the
// size of this array is likely to be very small, meaning indexOf is
// likely to be faster than Set.prototype.has.
var sets = context.written[dataId] || (context.written[dataId] = []);
if (sets.indexOf(selectionSet) >= 0)
return dataRef;
sets.push(selectionSet);
// If we're about to write a result object into the store, but we
// happen to know that the exact same (===) result object would be
// returned if we were to reread the result with the same inputs,
// then we can skip the rest of the processSelectionSet work for
// this object, and immediately return a Reference to it.
if (this.reader &&
this.reader.isFresh(result, dataRef, selectionSet, context)) {
return dataRef;
}
var previous_1 = context.incomingById.get(dataId);
if (previous_1) {
previous_1.storeObject = context.merge(previous_1.storeObject, incoming);
previous_1.mergeTree = mergeMergeTrees(previous_1.mergeTree, mergeTree);
fieldNodeSet.forEach(function (field) { return previous_1.fieldNodeSet.add(field); });
}
else {
context.incomingById.set(dataId, {
storeObject: incoming,
// Save a reference to mergeTree only if it is not empty, because
// empty MergeTrees may be recycled by maybeRecycleChildMergeTree and
// reused for entirely different parts of the result tree.
mergeTree: mergeTreeIsEmpty(mergeTree) ? void 0 : mergeTree,
fieldNodeSet: fieldNodeSet,
});
}
return dataRef;
}
return incoming;
};
StoreWriter.prototype.processFieldValue = function (value, field, context, mergeTree) {
var _this = this;
if (!field.selectionSet || value === null) {
// In development, we need to clone scalar values so that they can be
// safely frozen with maybeDeepFreeze in readFromStore.ts. In production,
// it's cheaper to store the scalar values directly in the cache.
return globalThis.__DEV__ !== false ? cloneDeep(value) : value;
}
if (isArray(value)) {
return value.map(function (item, i) {
var value = _this.processFieldValue(item, field, context, getChildMergeTree(mergeTree, i));
maybeRecycleChildMergeTree(mergeTree, i);
return value;
});
}
return this.processSelectionSet({
result: value,
selectionSet: field.selectionSet,
context: context,
mergeTree: mergeTree,
});
};
// Implements https://spec.graphql.org/draft/#sec-Field-Collection, but with
// some additions for tracking @client and @defer directives.
StoreWriter.prototype.flattenFields = function (selectionSet, result, context, typename) {
if (typename === void 0) { typename = getTypenameFromResult(result, selectionSet, context.fragmentMap); }
var fieldMap = new Map();
var policies = this.cache.policies;
var limitingTrie = new Trie(false); // No need for WeakMap, since limitingTrie does not escape.
(function flatten(selectionSet, inheritedContext) {
var visitedNode = limitingTrie.lookup(selectionSet,
// Because we take inheritedClientOnly and inheritedDeferred into
// consideration here (in addition to selectionSet), it's possible for
// the same selection set to be flattened more than once, if it appears
// in the query with different @client and/or @directive configurations.
inheritedContext.clientOnly, inheritedContext.deferred);
if (visitedNode.visited)
return;
visitedNode.visited = true;
selectionSet.selections.forEach(function (selection) {
if (!shouldInclude(selection, context.variables))
return;
var clientOnly = inheritedContext.clientOnly, deferred = inheritedContext.deferred;
if (
// Since the presence of @client or @defer on this field can only
// cause clientOnly or deferred to become true, we can skip the
// forEach loop if both clientOnly and deferred are already true.
!(clientOnly && deferred) &&
isNonEmptyArray(selection.directives)) {
selection.directives.forEach(function (dir) {
var name = dir.name.value;
if (name === "client")
clientOnly = true;
if (name === "defer") {
var args = argumentsObjectFromField(dir, context.variables);
// The @defer directive takes an optional args.if boolean
// argument, similar to @include(if: boolean). Note that
// @defer(if: false) does not make context.deferred false, but
// instead behaves as if there was no @defer directive.
if (!args || args.if !== false) {
deferred = true;
}
// TODO In the future, we may want to record args.label using
// context.deferred, if a label is specified.
}
});
}
if (isField(selection)) {
var existing = fieldMap.get(selection);
if (existing) {
// If this field has been visited along another recursive path
// before, the final context should have clientOnly or deferred set
// to true only if *all* paths have the directive (hence the &&).
clientOnly = clientOnly && existing.clientOnly;
deferred = deferred && existing.deferred;
}
fieldMap.set(selection, getContextFlavor(context, clientOnly, deferred));
}
else {
var fragment = getFragmentFromSelection(selection, context.lookupFragment);
if (!fragment && selection.kind === Kind.FRAGMENT_SPREAD) {
throw newInvariantError(13, selection.name.value);
}
if (fragment &&
policies.fragmentMatches(fragment, typename, result, context.variables)) {
flatten(fragment.selectionSet, getContextFlavor(context, clientOnly, deferred));
}
}
});
})(selectionSet, context);
return fieldMap;
};
StoreWriter.prototype.applyMerges = function (mergeTree, existing, incoming, context, getStorageArgs) {
var _a;
var _this = this;
if (mergeTree.map.size && !isReference(incoming)) {
var e_1 =
// Items in the same position in different arrays are not
// necessarily related to each other, so when incoming is an array
// we process its elements as if there was no existing data.
(!isArray(incoming) &&
// Likewise, existing must be either a Reference or a StoreObject
// in order for its fields to be safe to merge with the fields of
// the incoming object.
(isReference(existing) || storeValueIsStoreObject(existing))) ?
existing
: void 0;
// This narrowing is implied by mergeTree.map.size > 0 and
// !isReference(incoming), though TypeScript understandably cannot
// hope to infer this type.
var i_1 = incoming;
// The options.storage objects provided to read and merge functions
// are derived from the identity of the parent object plus a
// sequence of storeFieldName strings/numbers identifying the nested
// field name path of each field value to be merged.
if (e_1 && !getStorageArgs) {
getStorageArgs = [isReference(e_1) ? e_1.__ref : e_1];
}
// It's possible that applying merge functions to this subtree will
// not change the incoming data, so this variable tracks the fields
// that did change, so we can create a new incoming object when (and
// only when) at least one incoming field has changed. We use a Map
// to preserve the type of numeric keys.
var changedFields_1;
var getValue_1 = function (from, name) {
return (isArray(from) ?
typeof name === "number" ?
from[name]
: void 0
: context.store.getFieldValue(from, String(name)));
};
mergeTree.map.forEach(function (childTree, storeFieldName) {
var eVal = getValue_1(e_1, storeFieldName);
var iVal = getValue_1(i_1, storeFieldName);
// If we have no incoming data, leave any existing data untouched.
if (void 0 === iVal)
return;
if (getStorageArgs) {
getStorageArgs.push(storeFieldName);
}
var aVal = _this.applyMerges(childTree, eVal, iVal, context, getStorageArgs);
if (aVal !== iVal) {
changedFields_1 = changedFields_1 || new Map();
changedFields_1.set(storeFieldName, aVal);
}
if (getStorageArgs) {
invariant(getStorageArgs.pop() === storeFieldName);
}
});
if (changedFields_1) {
// Shallow clone i so we can add changed fields to it.
incoming = (isArray(i_1) ? i_1.slice(0) : __assign({}, i_1));
changedFields_1.forEach(function (value, name) {
incoming[name] = value;
});
}
}
if (mergeTree.info) {
return this.cache.policies.runMergeFunction(existing, incoming, mergeTree.info, context, getStorageArgs && (_a = context.store).getStorage.apply(_a, getStorageArgs));
}
return incoming;
};
return StoreWriter;
}());
export { StoreWriter };
var emptyMergeTreePool = [];
function getChildMergeTree(_a, name) {
var map = _a.map;
if (!map.has(name)) {
map.set(name, emptyMergeTreePool.pop() || { map: new Map() });
}
return map.get(name);
}
function mergeMergeTrees(left, right) {
if (left === right || !right || mergeTreeIsEmpty(right))
return left;
if (!left || mergeTreeIsEmpty(left))
return right;
var info = left.info && right.info ? __assign(__assign({}, left.info), right.info) : left.info || right.info;
var needToMergeMaps = left.map.size && right.map.size;
var map = needToMergeMaps ? new Map()
: left.map.size ? left.map
: right.map;
var merged = { info: info, map: map };
if (needToMergeMaps) {
var remainingRightKeys_1 = new Set(right.map.keys());
left.map.forEach(function (leftTree, key) {
merged.map.set(key, mergeMergeTrees(leftTree, right.map.get(key)));
remainingRightKeys_1.delete(key);
});
remainingRightKeys_1.forEach(function (key) {
merged.map.set(key, mergeMergeTrees(right.map.get(key), left.map.get(key)));
});
}
return merged;
}
function mergeTreeIsEmpty(tree) {
return !tree || !(tree.info || tree.map.size);
}
function maybeRecycleChildMergeTree(_a, name) {
var map = _a.map;
var childTree = map.get(name);
if (childTree && mergeTreeIsEmpty(childTree)) {
emptyMergeTreePool.push(childTree);
map.delete(name);
}
}
var warnings = new Set();
// Note that this function is unused in production, and thus should be
// pruned by any well-configured minifier.
function warnAboutDataLoss(existingRef, incomingObj, storeFieldName, store) {
var getChild = function (objOrRef) {
var child = store.getFieldValue(objOrRef, storeFieldName);
return typeof child === "object" && child;
};
var existing = getChild(existingRef);
if (!existing)
return;
var incoming = getChild(incomingObj);
if (!incoming)
return;
// It's always safe to replace a reference, since it refers to data
// safely stored elsewhere.
if (isReference(existing))
return;
// If the values are structurally equivalent, we do not need to worry
// about incoming replacing existing.
if (equal(existing, incoming))
return;
// If we're replacing every key of the existing object, then the
// existing data would be overwritten even if the objects were
// normalized, so warning would not be helpful here.
if (Object.keys(existing).every(function (key) { return store.getFieldValue(incoming, key) !== void 0; })) {
return;
}
var parentType = store.getFieldValue(existingRef, "__typename") ||
store.getFieldValue(incomingObj, "__typename");
var fieldName = fieldNameFromStoreName(storeFieldName);
var typeDotName = "".concat(parentType, ".").concat(fieldName);
// Avoid warning more than once for the same type and field name.
if (warnings.has(typeDotName))
return;
warnings.add(typeDotName);
var childTypenames = [];
// Arrays do not have __typename fields, and always need a custom merge
// function, even if their elements are normalized entities.
if (!isArray(existing) && !isArray(incoming)) {
[existing, incoming].forEach(function (child) {
var typename = store.getFieldValue(child, "__typename");
if (typeof typename === "string" && !childTypenames.includes(typename)) {
childTypenames.push(typename);
}
});
}
globalThis.__DEV__ !== false && invariant.warn(14, fieldName, parentType, childTypenames.length ?
"either ensure all objects of type " +
childTypenames.join(" and ") +
" have an ID or a custom merge function, or "
: "", typeDotName, __assign({}, existing), __assign({}, incoming));
}
//# sourceMappingURL=writeToStore.js.map