UNPKG

@apollo/client

Version:

A fully-featured caching GraphQL client.

735 lines (733 loc) 35.1 kB
"use strict";; const { __DEV__ } = require("@apollo/client/utilities/environment"); Object.defineProperty(exports, "__esModule", { value: true }); exports.StoreReader = void 0; const equality_1 = require("@wry/equality"); const trie_1 = require("@wry/trie"); const graphql_1 = require("graphql"); const optimism_1 = require("optimism"); const utilities_1 = require("@apollo/client/utilities"); const environment_1 = require("@apollo/client/utilities/environment"); const internal_1 = require("@apollo/client/utilities/internal"); const invariant_1 = require("@apollo/client/utilities/invariant"); const common_js_1 = require("../core/types/common.cjs"); const entityStore_js_1 = require("./entityStore.cjs"); const helpers_js_1 = require("./helpers.cjs"); function execSelectionSetKeyArgs(options) { return [options.selectionSet, options.objectOrReference, options.context]; } class StoreReader { // cached version of executeSelectionSet executeSelectionSet; // cached version of executeSubSelectedArray executeSubSelectedArray; prunePartialStreamArray; prunePartialBoundaries; config; knownResults = new WeakMap(); keyMaker = new trie_1.Trie(); constructor(config) { this.config = config; // memoized functions in this class will be "garbage-collected" // by recreating the whole `StoreReader` in // `InMemoryCache.resetResultsCache` // (triggered from `InMemoryCache.gc` with `resetResultCache: true`) this.executeSelectionSet = (0, optimism_1.wrap)((options) => { const peekArgs = execSelectionSetKeyArgs(options); const other = this.executeSelectionSet.peek(...peekArgs); if (other) { // If we previously read this result with canonization enabled, we can // return that canonized result as-is. return other; } (0, entityStore_js_1.maybeDependOnExistenceOfEntity)(options.context.store, options.enclosingRef.__ref); // Finally, if we didn't find any useful previous results, run the real // execSelectionSetImpl method with the given options. return this.execSelectionSetImpl(options); }, { max: utilities_1.cacheSizes["inMemoryCache.executeSelectionSet"] || 50000 /* defaultCacheSizes["inMemoryCache.executeSelectionSet"] */, keyArgs: execSelectionSetKeyArgs, // Note that the parameters of makeCacheKey are determined by the // array returned by keyArgs. makeCacheKey(selectionSet, parent, context) { if ((0, entityStore_js_1.supportsResultCaching)(context.store)) { return context.store.makeCacheKey(selectionSet, (0, utilities_1.isReference)(parent) ? parent.__ref : parent, context.varString); } }, }); this.executeSubSelectedArray = (0, optimism_1.wrap)((options) => { (0, entityStore_js_1.maybeDependOnExistenceOfEntity)(options.context.store, options.enclosingRef.__ref); return this.execSubSelectedArrayImpl(options); }, { max: utilities_1.cacheSizes["inMemoryCache.executeSubSelectedArray"] || 10000 /* defaultCacheSizes["inMemoryCache.executeSubSelectedArray"] */, makeCacheKey({ field, array, context }) { if ((0, entityStore_js_1.supportsResultCaching)(context.store)) { return context.store.makeCacheKey(field, array, context.varString); } }, }); this.prunePartialBoundaries = (0, optimism_1.wrap)((options) => this.prunePartialBoundariesImpl(options), { max: utilities_1.cacheSizes["inMemoryCache.prunePartialBoundaries"] || 20000 /* defaultCacheSizes["inMemoryCache.prunePartialBoundaries"] */, makeCacheKey: ({ boundaries, context, selectionSet }) => { if ((0, entityStore_js_1.supportsResultCaching)(context.store)) { return this.keyMaker.lookup(selectionSet, boundaries, context.streamInfo, context.deferInfo); } }, }); this.prunePartialStreamArray = (0, optimism_1.wrap)((options) => { const { field, context, path } = options; if ((0, internal_1.isStreamField)(field, context.variables)) { context.streamInfo?.lookupArray(path).state.depend(); } return this.prunePartialStreamArrayImpl(options); }, { max: utilities_1.cacheSizes["inMemoryCache.prunePartialStreamArray"] || 20000 /* defaultCacheSizes["inMemoryCache.prunePartialStreamArray"] */, makeCacheKey: ({ field, context, boundaries }) => { if ((0, entityStore_js_1.supportsResultCaching)(context.store)) { return this.keyMaker.lookup(field, boundaries, context.streamInfo, context.deferInfo); } }, }); } diffQueryAgainstStore({ store, query, rootId = "ROOT_QUERY", variables, returnPartialData = true, ...options }) { const returnIncremental = Object.hasOwn(options, internal_1.handleIncrementalSymbol); const policies = this.config.cache.policies; variables = (0, internal_1.compact)((0, internal_1.getDefaultValues)((0, internal_1.getQueryDefinition)(query)), variables); const rootRef = (0, internal_1.makeReference)(rootId); const context = { store, query, policies, variables, varString: (0, utilities_1.canonicalStringify)(variables), ...(0, helpers_js_1.extractFragmentContext)(query, this.config.fragments), ...options[internal_1.handleIncrementalSymbol], }; let execResult = this.executeSelectionSet({ selectionSet: (0, internal_1.getMainDefinition)(query).selectionSet, objectOrReference: rootRef, enclosingRef: rootRef, context, }); // Since executeSelectionSet doesn't know about returnPartialData, we need // to perform a 2nd pass over the result to prune any fields inside // partial defer boundaries. The "deferPartial" data state tells us that the // only part of the result that contributed to its partiality is data inside // a defer boundary. if (returnIncremental && shouldPrune(execResult, context)) { const pruned = this.prunePartialBoundaries({ selectionSet: (0, internal_1.getMainDefinition)(query).selectionSet, data: execResult.result, boundaries: execResult.partialBoundaries, context, path: [], }); const changed = execResult.result !== pruned.result; // It's possible that pruning didn't actually change the result which can // happen if a defer boundary is misclassified as "deferPartial" instead // of "streaming" (sibling defer boundaries with overlapping selection // sets, one of which is complete). In this case, pruning corrects the // dataState to streaming instead of leaving it as partial. If we tolerate // partial results and pruning changed the result by dropping fields, then // we want to keep the original execResult which contains the partial // data. if (!changed || !returnPartialData) { // Omit `missing` property since pruning puts it in a state that doesn't // report missing fields. execResult = { result: pruned.result, partialBoundaries: execResult.partialBoundaries, dataState: pruned.dataState, }; } } let { result, dataState, missing } = execResult; // Evaluate this condition before we start mucking with dataState for the // publicly returned value const includeMissing = !!missing && // We don't need to report missing fields inside defer boundaries since // the "streaming" dataState tells us that the only missing fields in // the object is inside a defer boundary. (dataState !== "streaming" || !returnIncremental); // If we get all root @defer boundaries with an empty result, report it as // empty instead of streaming. if (dataState === "streaming" && Object.keys(result).length === 0) { dataState = "empty"; } let missingError; if (dataState === "deferPartial" || dataState === "streamPartial" || (dataState === "streaming" && !returnIncremental)) { dataState = "partial"; } if (dataState === "partial" && !returnPartialData) { dataState = "empty"; } const complete = dataState === "complete"; const keepResult = complete || dataState === "streaming" || (returnPartialData && Object.keys(result).length); const diffResult = { result: keepResult ? result : null, complete, get missing() { if (includeMissing) { missingError ||= new common_js_1.MissingFieldError(firstMissing(missing), missing, query, variables); } return missingError; }, }; if (returnIncremental) { diffResult.dataState = dataState; } return diffResult; } isFresh(result, parent, selectionSet, context) { if ((0, entityStore_js_1.supportsResultCaching)(context.store) && this.knownResults.get(result) === selectionSet) { const latest = this.executeSelectionSet.peek(selectionSet, parent, context); if (latest && result === latest.result) { return true; } } return false; } // Uncached version of executeSelectionSet. execSelectionSetImpl({ selectionSet, objectOrReference, enclosingRef, context, }) { if ((0, utilities_1.isReference)(objectOrReference) && !context.policies.rootTypenamesById[objectOrReference.__ref] && !context.store.has(objectOrReference.__ref)) { return { result: {}, dataState: "empty", missing: `Dangling reference to missing ${objectOrReference.__ref} object`, partialBoundaries: new PartialBoundaries(), }; } const { variables, policies, store } = context; const typename = store.getFieldValue(objectOrReference, "__typename"); const objectsToMerge = []; let dataState; let missing; const missingMerger = new internal_1.DeepMerger(); const partialBoundaries = new PartialBoundaries(); if (typeof typename === "string" && !policies.rootIdsByTypename[typename]) { // Ensure we always include a default value for the __typename // field, if we have one. Note that this field can be overridden by other // merged objects. objectsToMerge.push({ __typename: typename }); } function handleMissing(result, resultName) { if (result.missing) { missing = missingMerger.merge(missing, { [resultName]: result.missing, }); } return result; } const workSet = new Set(selectionSet.selections); workSet.forEach((selection) => { // Omit fields with directives @skip(if: <truthy value>) or // @include(if: <falsy value>). if (!(0, internal_1.shouldInclude)(selection, variables)) return; if ((0, internal_1.isField)(selection)) { let fieldValue = policies.readField({ fieldName: selection.name.value, field: selection, variables: context.variables, from: objectOrReference, }, context); const resultName = (0, internal_1.resultKeyNameFromField)(selection); if (fieldValue === void 0) { if (!utilities_1.addTypenameToDocument.added(selection)) { const id = (0, utilities_1.isReference)(objectOrReference) ? objectOrReference.__ref : objectOrReference ? policies.identify(objectOrReference)[0] : undefined; missing = missingMerger.merge(missing, { [resultName]: `Can't find field '${selection.name.value}' on ${id ? `${id} object` : `object ${JSON.stringify(objectOrReference || {}, null, 2)}`}`, }); dataState = mergeDataState(dataState, "empty"); } } else if ((0, internal_1.isArray)(fieldValue)) { if (fieldValue.length > 0) { const execResult = handleMissing(this.executeSubSelectedArray({ field: selection, array: fieldValue, enclosingRef, context, }), resultName); fieldValue = execResult.result; dataState = mergeDataState(dataState, execResult.dataState); partialBoundaries.set(resultName, execResult.partialBoundaries); } else { dataState = mergeDataState(dataState, "complete"); } } else if (!selection.selectionSet) { // Auto-inserted __typename should not affect dataState (see empty // @defer boundaries, which must stay "empty" → parent "streaming"). if (!utilities_1.addTypenameToDocument.added(selection)) { dataState = mergeDataState(dataState, "complete"); } } else if (fieldValue != null) { if (environment_1.__DEV__) { const fieldName = selection.name.value; if (typename) { const policy = policies["getFieldPolicy"](typename, fieldName); if (policy?.scalar) { __DEV__ && invariant_1.invariant.warn(113, `${typename}.${fieldName}`, policy.scalar); } } } // In this case, because we know the field has a selection set, // it must be trying to query a GraphQLObjectType, which is why // fieldValue must be != null. const execResult = handleMissing(this.executeSelectionSet({ selectionSet: selection.selectionSet, objectOrReference: fieldValue, enclosingRef: (0, utilities_1.isReference)(fieldValue) ? fieldValue : enclosingRef, context, }), resultName); fieldValue = execResult.result; partialBoundaries.set(resultName, execResult.partialBoundaries); // If the object's fields resolved to an "empty" dataState (e.g. no // field resolved with a non-undefined value), but the fieldValue // object itself is present, this object should be considered // partial instead of empty. This also ensures defer boundaries that // select this object remain as partial defer boundaries rather than // mistakenly get reported as streaming. This is especially necessary // when combined with GraphQL Codegen which generates its type and // relies on the outer object to be absent when its fields haven't // streamed in. Reporting the defer boundary as "streaming" instead of // "partial" would otherwise have the potential to cause runtime // crashes since the runtime values and types would not line up // properly (types expect object to be undefined, but its instead // present without its fields) dataState = mergeDataState(dataState, execResult.dataState === "empty" ? "partial" : execResult.dataState); } if (fieldValue !== void 0) { objectsToMerge.push({ [resultName]: fieldValue }); } } else { const fragment = (0, internal_1.getFragmentFromSelection)(selection, context.lookupFragment); if (!fragment && selection.kind === graphql_1.Kind.FRAGMENT_SPREAD) { throw (0, invariant_1.newInvariantError)(114, selection.name.value); } if (fragment && policies.fragmentMatches(fragment, typename)) { const isDeferBoundary = (0, internal_1.isDeferredFragment)(selection, context.variables); // Prior to 4.3, this branch just flattened the fragment's // selectionSet into the existing workSet so that it continued // iterating as if the fragment didn't exist. The cache is // incremental aware as of 4.3 and as such, we need to resolve the // per-fragment selection set so that we can properly strip partial // defer fragment data when returnPartialData is false. We need to // call execSelectionSetImpl directly (non-cached version) so that we // scope the dataState correctly for its fields. Using the cached // executeSelectionSet can result in cache poisoning when combined // with the fragment registry where it might cache either a) an error // thrown when a registered fragment references a named fragment that // the query is expected to supply and doesn't or b) resolve to the // wrong data result when combined with queries that provide different // implementations of the same fragment (see inmemory/fragmentRegistry and // cache.diff/incremental tests which provide guards against this // behavior). const execResult = this.execSelectionSetImpl({ selectionSet: fragment.selectionSet, objectOrReference, enclosingRef, context, }); const { result, dataState: nextDataState } = execResult; partialBoundaries.merge(execResult.partialBoundaries); if (result !== void 0) { objectsToMerge.push(result); } if (execResult.missing) { missing = missingMerger.merge(missing, execResult.missing); } if (isDeferBoundary && (nextDataState === "partial" || nextDataState === "empty")) { partialBoundaries.add(selection); } dataState = mergeDataState(dataState, isDeferBoundary ? nextDataState === "empty" ? "streaming" : nextDataState === "partial" ? "deferPartial" : nextDataState : nextDataState); } } }); dataState ||= "complete"; const result = (0, internal_1.mergeDeepArray)(objectsToMerge); const finalResult = { result, missing, dataState, partialBoundaries, }; const frozen = (0, internal_1.maybeDeepFreeze)(finalResult); // Store this result with its selection set so that we can quickly // recognize it again in the StoreReader#isFresh method. if (frozen.result) { this.knownResults.set(frozen.result, selectionSet); } return frozen; } // Uncached version of executeSubSelectedArray. execSubSelectedArrayImpl({ field, array, enclosingRef, context, }) { let dataState = "complete"; let missing; let missingMerger = new internal_1.DeepMerger(); const partialBoundaries = new PartialBoundaries(); const isStreamed = (0, internal_1.isStreamField)(field, context.variables); function handleMissing(childResult, i) { if (childResult.missing) { missing = missingMerger.merge(missing, { [i]: childResult.missing }); } return childResult.result; } if (field.selectionSet) { array = array.filter((item) => item === undefined || context.store.canRead(item)); } array = array.map((item, i) => { // null value in array if (item === null) { return null; } let execResult; // This is a nested array, recurse if ((0, internal_1.isArray)(item)) { execResult = this.executeSubSelectedArray({ field, array: item, enclosingRef, context, }); } else if (field.selectionSet) { execResult = this.executeSelectionSet({ selectionSet: field.selectionSet, objectOrReference: item, enclosingRef: (0, utilities_1.isReference)(item) ? item : enclosingRef, context, }); } if (execResult) { const { dataState: nextDataState } = execResult; partialBoundaries.set(i, nextDataState === "partial" ? // avoid mutating the execResult partialBoundaries object execResult.partialBoundaries.clone().add(field) : execResult.partialBoundaries); dataState = mergeDataState(dataState, isStreamed ? nextDataState === "partial" ? "streamPartial" : nextDataState : nextDataState); return handleMissing(execResult, i); } if (environment_1.__DEV__) { assertSelectionSetForIdValue(context.store, field, item); } return item; }); return { result: array, dataState, missing, partialBoundaries, }; } prunePartialBoundariesImpl({ boundaries, context, data, path, selectionSet, }) { const { variables, lookupFragment, policies } = context; if (data == null || !boundaries) { return { result: data, dataState: "complete" }; } const merger = new internal_1.DeepMerger(); let changed = false; let dataState = "complete"; const result = {}; // __typename might not be part of the selection set, so preserve it when // available, otherwise it gets removed since it's never visited when // iterating the selection set. if (Object.hasOwn(data, "__typename")) { result.__typename = data.__typename; } const workSet = new Set(selectionSet.selections); workSet.forEach((selection) => { if (!(0, internal_1.shouldInclude)(selection, variables)) return; if ((0, internal_1.isField)(selection)) { const resultName = (0, internal_1.resultKeyNameFromField)(selection); if (!Object.hasOwn(data, resultName)) { return; } const fieldValue = data[resultName]; if (Array.isArray(fieldValue)) { const pruned = this.prunePartialStreamArray({ field: selection, array: fieldValue, boundaries: boundaries.getChild(resultName), context, path: path.concat(resultName), }); changed ||= pruned.result !== fieldValue; result[resultName] = pruned.result; dataState = mergeDataState(dataState, pruned.dataState); } else if (!selection.selectionSet) { result[resultName] = fieldValue; } else { const pruned = this.prunePartialBoundaries({ data: fieldValue, selectionSet: selection.selectionSet, boundaries: boundaries.getChild(resultName), context, path: path.concat(resultName), }); changed ||= pruned.result !== fieldValue; dataState = mergeDataState(dataState, pruned.dataState); // A response key can be selected by more than one selection (e.g. a // field and an overlapping fragment), so merge their kept fields. result[resultName] = Object.hasOwn(result, resultName) ? merger.merge(result[resultName], pruned.result) : pruned.result; } return; } // Note: we do NOT set `changed` to true anywhere in this branch of the // conditional, despite the fact that we might have encountered a // partial @defer boundary. Dropping a fragment does not guarantee keys // are actually dropped which can happen when overlapping sibling // selections contribute to the construction of the object. The final // Object.keys(result).length check actually detects whether keys were // dropped or not. const fragment = (0, internal_1.getFragmentFromSelection)(selection, lookupFragment); let prune = false; if (context.deferInfo && (0, internal_1.isDeferredFragment)(selection, variables)) { const directive = selection.directives?.find((d) => d.name.value === "defer"); const label = directive && (0, internal_1.getDirectiveArgValue)(directive, "label", graphql_1.Kind.STRING); prune = !!context.deferInfo.peekArray(path.concat(label || [])); } if (fragment && policies.fragmentMatches(fragment, data.__typename)) { if (boundaries.has(selection) || prune) { dataState = mergeDataState(dataState, "streaming"); } else { fragment.selectionSet.selections.forEach(workSet.add, workSet); } } }); if (Object.keys(result).length !== Object.keys(data).length) { changed = true; } else if (changed && boundaries.hasSelections()) { // Overlapping siblings may rebuild the same fields under a new object // identity (e.g. changed === true) after a partial @defer is skipped. // We perform a deep equality check to verify whether anything was // actually dropped by the partial @defer fragment. changed = !(0, equality_1.equal)(result, data); } return { result: changed ? result : data, dataState }; } prunePartialStreamArrayImpl({ field, array, boundaries, context, path, }) { if (!boundaries) return { result: array, dataState: "complete" }; let changed = false; let dataState = "complete"; let pruned = []; const state = context.streamInfo?.peekArray(path)?.state; const length = Math.min(array.length, state?.truncate ? state.streamPosition : Number.MAX_SAFE_INTEGER); for (let i = 0; i < length; i++) { const item = array[i]; let prunedResult = { result: item, dataState: "complete", }; const boundary = boundaries.getChild(i); if (boundary?.has(field)) { // The presence of streamInfo determines how we truncate partial // stream arrays. Stream info is only given to cache.diff during // in-flight requests so we want keep items in the array equal to the // total that have streamed in (this is represented by streamPosition // above). For all other cache reads, partial stream boundaries are // pruned back to an empty array. if (state) { state.truncate = true; pruned = pruned.slice(0, state.streamPosition); } else { pruned = []; dataState = "complete"; } break; } if (Array.isArray(item)) { prunedResult = this.prunePartialStreamArray({ field, array: item, boundaries: boundaries.getChild(i), context, path: path.concat(i), }); } else if (field.selectionSet) { prunedResult = this.prunePartialBoundaries({ data: item, selectionSet: field.selectionSet, boundaries: boundaries.getChild(i), context, path: path.concat(i), }); } pruned.push(prunedResult.result); changed ||= prunedResult.result !== item; dataState = mergeDataState(dataState, prunedResult.dataState); } changed ||= pruned.length !== array.length; return { result: changed ? pruned : array, dataState }; } } exports.StoreReader = StoreReader; function firstMissing(tree) { try { JSON.stringify(tree, (_, value) => { if (typeof value === "string") throw value; return value; }); } catch (result) { return result; } } function assertSelectionSetForIdValue(store, field, fieldValue) { if (!field.selectionSet) { const workSet = new Set([fieldValue]); workSet.forEach((value) => { if ((0, internal_1.isNonNullObject)(value)) { (0, invariant_1.invariant)( !(0, utilities_1.isReference)(value), 115, (0, helpers_js_1.getTypenameFromStoreObject)(store, value), field.name.value ); Object.values(value).forEach(workSet.add, workSet); } }); } } // We deliberately leave `returnPartialData` out of `executeSelectionSet`'s // cache key. `isFresh` runs during writes and cannot provide a reliable value // for this option, so including it would prevent a reliable cache hit. // // When `returnPartialData` is false, `diffQueryAgainstStore` prunes data from // partial @defer boundaries after reading the cached result. `PartialBoundaries` // records the selection paths needed for that pass, including empty nodes along // a path. Overlapping non-deferred selections must still be rebuilt so fields // contributed only by a partial deferred sibling are removed. The prune pass // can then skip unrelated result branches. class PartialBoundaries { selections = new Set(); children = new Map(); add(selection) { this.selections.add(selection); return this; } has(selection) { return this.selections.has(selection); } hasSelections() { return this.selections.size > 0; } getChild(key) { return this.children.get(key); } clone() { return new PartialBoundaries().merge(this); } set(key, boundary) { const child = this.getChild(key); this.children.set(key, // Create a new PartialBoundaries instance to avoid mutating any cached // execResult partialBoundaries objects child ? child.clone().merge(boundary) : boundary); } merge(boundaries) { boundaries.selections.forEach((selection) => this.add(selection)); boundaries.children.forEach((child, key) => this.set(key, child)); return this; } } function shouldPrune({ dataState }, context) { if (dataState === "deferPartial" || dataState === "streamPartial") { return true; } if (dataState === "complete" || dataState === "streaming") { return !!( // If the last cache write repaired a partial @stream array to a // complete array, the stream array might contain stale entries after // the last written value. We only want to deliver the results up to // the index the network wrote so we need to prune it too. (context.streamInfo || // The network hasn't delivered these @defer boundaries yet, so prune // the (possibly complete) cached data sitting at them. context.deferInfo)); } return false; } // Describes the data state transitions that change the running state when it's // combined with the next data state. Omitted object values represent // "impossible" merges where the data state should remain the same. const DATA_STATE_MERGES = { empty: { complete: "partial", deferPartial: "partial", streaming: "partial", streamPartial: "partial", }, deferPartial: { empty: "partial", }, streamPartial: { deferPartial: "deferPartial", empty: "partial", }, streaming: { deferPartial: "deferPartial", empty: "partial", streamPartial: "streamPartial", }, complete: { deferPartial: "deferPartial", streaming: "streaming", streamPartial: "streamPartial", empty: "partial", }, partial: {}, }; function mergeDataState(current, next) { if (next === "partial") { return "partial"; } if (!current || current === next) { return next; } return DATA_STATE_MERGES[current][next] || current; } //# sourceMappingURL=readFromStore.cjs.map