UNPKG

relay-runtime

Version:

A core runtime for building GraphQL-driven applications.

930 lines (855 loc) • 29.6 kB
/** * 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 {DataID, Disposable} from '../util/RelayRuntimeTypes'; import type {Availability} from './DataChecker'; import type {UpdatedRecords} from './live-resolvers/LiveResolverCache'; import type {GetDataID} from './RelayResponseNormalizer'; import type {NormalizationOptions} from './RelayResponseNormalizer'; import type { CheckOptions, DataIDSet, LogFunction, MutableRecordSource, OperationAvailability, OperationDescriptor, OperationLoader, RecordSource, RequestDescriptor, ResolverContext, Scheduler, SingularReaderSelector, Snapshot, Store, StoreSubscriptions, } from './RelayStoreTypes'; const { INTERNAL_ACTOR_IDENTIFIER_DO_NOT_USE, assertInternalActorIdentifier, } = require('../multi-actor-environment/ActorIdentifier'); const deepFreeze = require('../util/deepFreeze'); const resolveImmediate = require('../util/resolveImmediate'); const DataChecker = require('./DataChecker'); const defaultGetDataID = require('./defaultGetDataID'); const { LiveResolverCache, RELAY_RESOLVER_LIVE_STATE_SUBSCRIPTION_KEY, getUpdatedDataIDs, } = require('./live-resolvers/LiveResolverCache'); const RelayModernRecord = require('./RelayModernRecord'); const RelayOptimisticRecordSource = require('./RelayOptimisticRecordSource'); const RelayReader = require('./RelayReader'); const RelayReferenceMarker = require('./RelayReferenceMarker'); const RelayStoreSubscriptions = require('./RelayStoreSubscriptions'); const RelayStoreUtils = require('./RelayStoreUtils'); const {ROOT_ID, ROOT_TYPE} = require('./RelayStoreUtils'); const invariant = require('invariant'); export opaque type InvalidationState = { dataIDs: $ReadOnlyArray<DataID>, invalidations: Map<DataID, ?number>, }; type InvalidationSubscription = { callback: () => void, invalidationState: InvalidationState, }; const DEFAULT_RELEASE_BUFFER_SIZE = 10; /** * @public * * An implementation of the `Store` interface defined in `RelayStoreTypes`. * * Note that a Store takes ownership of all records provided to it: other * objects may continue to hold a reference to such records but may not mutate * them. The static Relay core is architected to avoid mutating records that may have been * passed to a store: operations that mutate records will either create fresh * records or clone existing records and modify the clones. Record immutability * is also enforced in development mode by freezing all records passed to a store. */ class RelayModernStore implements Store { _currentWriteEpoch: number; _gcHoldCounter: number; _gcReleaseBufferSize: number; _gcRun: ?Generator<void, void, void>; _gcScheduler: Scheduler; _getDataID: GetDataID; _globalInvalidationEpoch: ?number; _invalidationSubscriptions: Set<InvalidationSubscription>; _invalidatedRecordIDs: DataIDSet; __log: ?LogFunction; _queryCacheExpirationTime: ?number; _operationLoader: ?OperationLoader; _optimisticSource: ?MutableRecordSource; _recordSource: MutableRecordSource; _resolverCache: LiveResolverCache; _releaseBuffer: Array<string>; _roots: Map< string, { operation: OperationDescriptor, refCount: number, epoch: ?number, fetchTime: ?number, }, >; _shouldScheduleGC: boolean; _storeSubscriptions: StoreSubscriptions; _updatedRecordIDs: DataIDSet; _shouldProcessClientComponents: ?boolean; _resolverContext: ?ResolverContext; _actorIdentifier: ?ActorIdentifier; _treatMissingFieldsAsNull: boolean; constructor( source: MutableRecordSource, options?: { gcScheduler?: ?Scheduler, log?: ?LogFunction, operationLoader?: ?OperationLoader, getDataID?: ?GetDataID, gcReleaseBufferSize?: ?number, queryCacheExpirationTime?: ?number, shouldProcessClientComponents?: ?boolean, resolverContext?: ResolverContext, // These additional config options are only used if the experimental // @outputType resolver feature is used treatMissingFieldsAsNull?: ?boolean, actorIdentifier?: ?ActorIdentifier, }, ) { // Prevent mutation of a record from outside the store. if (__DEV__) { const storeIDs = source.getRecordIDs(); for (let ii = 0; ii < storeIDs.length; ii++) { const record = source.get(storeIDs[ii]); if (record) { RelayModernRecord.freeze(record); } } } this._currentWriteEpoch = 0; this._gcHoldCounter = 0; this._gcReleaseBufferSize = options?.gcReleaseBufferSize ?? DEFAULT_RELEASE_BUFFER_SIZE; this._gcRun = null; this._gcScheduler = options?.gcScheduler ?? resolveImmediate; this._getDataID = options?.getDataID ?? defaultGetDataID; this._globalInvalidationEpoch = null; this._invalidationSubscriptions = new Set(); this._invalidatedRecordIDs = new Set(); this.__log = options?.log ?? null; this._queryCacheExpirationTime = options?.queryCacheExpirationTime; this._operationLoader = options?.operationLoader ?? null; this._optimisticSource = null; this._recordSource = source; this._releaseBuffer = []; this._roots = new Map(); this._shouldScheduleGC = false; this._resolverCache = new LiveResolverCache( () => this._getMutableRecordSource(), this, ); this._storeSubscriptions = new RelayStoreSubscriptions( options?.log, this._resolverCache, ); this._updatedRecordIDs = new Set(); this._shouldProcessClientComponents = options?.shouldProcessClientComponents ?? false; this._resolverContext = options?.resolverContext; this._treatMissingFieldsAsNull = options?.treatMissingFieldsAsNull ?? false; this._actorIdentifier = options?.actorIdentifier; initializeRecordSource(this._recordSource); } getSource(): RecordSource { return this._optimisticSource ?? this._recordSource; } getOperationLoader(): ?OperationLoader { return this._operationLoader; } _getMutableRecordSource(): MutableRecordSource { return this._optimisticSource ?? this._recordSource; } getLiveResolverPromise(recordID: DataID): Promise<void> { return this._resolverCache.getLiveResolverPromise(recordID); } /** * When an external data provider knows it's going to notify us about multiple * Live Resolver state updates in a single tick, it can batch them into a * single Relay update by notifying us within a batch. All updates received by * Relay during the evaluation of the provided `callback` will be aggregated * into a single Relay update. * * A typical use with a Flux store might look like this: * * const originalDispatch = fluxStore.dispatch; * * function wrapped(action) { * relayStore.batchLiveStateUpdates(() => { * originalDispatch(action); * }) * } * * fluxStore.dispatch = wrapped; */ batchLiveStateUpdates(callback: () => void) { if (this.__log != null) { this.__log({name: 'liveresolver.batch.start'}); } try { this._resolverCache.batchLiveStateUpdates(callback); } finally { if (this.__log != null) { this.__log({name: 'liveresolver.batch.end'}); } } } check( operation: OperationDescriptor, options?: CheckOptions, ): OperationAvailability { const selector = operation.root; const source = this._getMutableRecordSource(); const globalInvalidationEpoch = this._globalInvalidationEpoch; const rootEntry = this._roots.get(operation.request.identifier); const operationLastWrittenAt = rootEntry != null ? rootEntry.epoch : null; // Check if store has been globally invalidated if (globalInvalidationEpoch != null) { // If so, check if the operation we're checking was last written // before or after invalidation occurred. if ( operationLastWrittenAt == null || operationLastWrittenAt <= globalInvalidationEpoch ) { // If the operation was written /before/ global invalidation occurred, // or if this operation has never been written to the store before, // we will consider the data for this operation to be stale // (i.e. not resolvable from the store). return {status: 'stale'}; } } const handlers = options?.handlers ?? []; const getSourceForActor = options?.getSourceForActor ?? (actorIdentifier => { assertInternalActorIdentifier(actorIdentifier); return source; }); const getTargetForActor = options?.getTargetForActor ?? (actorIdentifier => { assertInternalActorIdentifier(actorIdentifier); return source; }); const operationAvailability = DataChecker.check( getSourceForActor, getTargetForActor, options?.defaultActorIdentifier ?? INTERNAL_ACTOR_IDENTIFIER_DO_NOT_USE, selector, handlers, this._operationLoader, this._getDataID, this._shouldProcessClientComponents, this.__log, ); return getAvailabilityStatus( operationAvailability, operationLastWrittenAt, rootEntry?.fetchTime, this._queryCacheExpirationTime, ); } retain(operation: OperationDescriptor): Disposable { const id = operation.request.identifier; let disposed = false; const dispose = () => { // Ensure each retain can only dispose once if (disposed) { return; } disposed = true; // For Flow: guard against the entry somehow not existing const rootEntry = this._roots.get(id); if (rootEntry == null) { return; } // Decrement the ref count: if it becomes zero it is eligible // for release. rootEntry.refCount--; if (rootEntry.refCount === 0) { const {_queryCacheExpirationTime} = this; const rootEntryIsStale = rootEntry.fetchTime != null && _queryCacheExpirationTime != null && rootEntry.fetchTime <= Date.now() - _queryCacheExpirationTime; if (rootEntryIsStale) { this._roots.delete(id); this.scheduleGC(); } else { this._releaseBuffer.push(id); // If the release buffer is now over-full, remove the least-recently // added entry and schedule a GC. Note that all items in the release // buffer have a refCount of 0. if (this._releaseBuffer.length > this._gcReleaseBufferSize) { const _id = this._releaseBuffer.shift(); // $FlowFixMe[incompatible-call] this._roots.delete(_id); this.scheduleGC(); } } } }; const rootEntry = this._roots.get(id); if (rootEntry != null) { if (rootEntry.refCount === 0) { // This entry should be in the release buffer, but it no longer belongs // there since it's retained. Remove it to maintain the invariant that // all release buffer entries have a refCount of 0. this._releaseBuffer = this._releaseBuffer.filter(_id => _id !== id); } // If we've previously retained this operation, increment the refCount rootEntry.refCount += 1; } else { // Otherwise create a new entry for the operation this._roots.set(id, { operation, refCount: 1, epoch: null, fetchTime: null, }); } return {dispose}; } lookup(selector: SingularReaderSelector): Snapshot { const log = this.__log; if (log != null) { log({ name: 'store.lookup.start', selector, }); } const source = this.getSource(); const snapshot = RelayReader.read( source, selector, this._resolverCache, this._resolverContext, ); if (__DEV__) { deepFreeze(snapshot); } if (log != null) { log({ name: 'store.lookup.end', selector, }); } return snapshot; } // This method will return a list of updated owners from the subscriptions notify( sourceOperation?: OperationDescriptor, invalidateStore?: boolean, ): $ReadOnlyArray<RequestDescriptor> { const log = this.__log; if (log != null) { log({ name: 'store.notify.start', sourceOperation, }); } // Increment the current write when notifying after executing // a set of changes to the store. this._currentWriteEpoch++; if (invalidateStore === true) { this._globalInvalidationEpoch = this._currentWriteEpoch; } // When a record is updated, we need to also handle records that depend on it, // specifically Relay Resolver result records containing results based on the // updated records. This both adds to updatedRecordIDs and invalidates any // cached data as needed. this._resolverCache.invalidateDataIDs(this._updatedRecordIDs); const source = this.getSource(); const updatedOwners: Array<RequestDescriptor> = []; this._storeSubscriptions.updateSubscriptions( source, this._updatedRecordIDs, updatedOwners, sourceOperation, ); this._invalidationSubscriptions.forEach(subscription => { this._updateInvalidationSubscription( subscription, invalidateStore === true, ); }); // If a source operation was provided (indicating the operation // that produced this update to the store), record the current epoch // at which this operation was written. if (sourceOperation != null) { // We only track the epoch at which the operation was written if // it was previously retained, to keep the size of our operation // epoch map bounded. If a query wasn't retained, we assume it can // may be deleted at any moment and thus is not relevant for us to track // for the purposes of invalidation. const id = sourceOperation.request.identifier; const rootEntry = this._roots.get(id); if (rootEntry != null) { rootEntry.epoch = this._currentWriteEpoch; rootEntry.fetchTime = Date.now(); } else if ( sourceOperation.request.node.params.operationKind === 'query' && this._gcReleaseBufferSize > 0 && this._releaseBuffer.length < this._gcReleaseBufferSize ) { // The operation isn't retained but there is space in the release buffer: // temporarily track this operation in case the data can be reused soon. const temporaryRootEntry = { operation: sourceOperation, refCount: 0, epoch: this._currentWriteEpoch, fetchTime: Date.now(), }; this._releaseBuffer.push(id); this._roots.set(id, temporaryRootEntry); } } if (log != null) { log({ name: 'store.notify.complete', sourceOperation, updatedRecordIDs: this._updatedRecordIDs, invalidatedRecordIDs: this._invalidatedRecordIDs, subscriptionsSize: this._storeSubscriptions.size(), updatedOwners, }); } this._updatedRecordIDs.clear(); this._invalidatedRecordIDs.clear(); return updatedOwners; } publish(source: RecordSource, idsMarkedForInvalidation?: DataIDSet): void { const target = this._getMutableRecordSource(); updateTargetFromSource( target, source, // We increment the current epoch at the end of the set of updates, // in notify(). Here, we pass what will be the incremented value of // the epoch to use to write to invalidated records. this._currentWriteEpoch + 1, idsMarkedForInvalidation, this._updatedRecordIDs, this._invalidatedRecordIDs, ); // NOTE: log *after* processing the source so that even if a bad log function // mutates the source, it doesn't affect Relay processing of it. const log = this.__log; if (log != null) { log({ name: 'store.publish', source, optimistic: target === this._optimisticSource, }); } } subscribe( snapshot: Snapshot, callback: (snapshot: Snapshot) => void, ): Disposable { return this._storeSubscriptions.subscribe(snapshot, callback); } holdGC(): Disposable { if (this._gcRun) { this._gcRun = null; this._shouldScheduleGC = true; } this._gcHoldCounter++; const dispose = () => { if (this._gcHoldCounter > 0) { this._gcHoldCounter--; if (this._gcHoldCounter === 0 && this._shouldScheduleGC) { this.scheduleGC(); this._shouldScheduleGC = false; } } }; return {dispose}; } toJSON(): mixed { return 'RelayModernStore()'; } getEpoch(): number { return this._currentWriteEpoch; } // Internal API __getUpdatedRecordIDs(): DataIDSet { return this._updatedRecordIDs; } lookupInvalidationState(dataIDs: $ReadOnlyArray<DataID>): InvalidationState { const invalidations = new Map<DataID, ?number>(); dataIDs.forEach(dataID => { const record = this.getSource().get(dataID); invalidations.set( dataID, RelayModernRecord.getInvalidationEpoch(record) ?? null, ); }); invalidations.set('global', this._globalInvalidationEpoch); return { dataIDs, invalidations, }; } checkInvalidationState(prevInvalidationState: InvalidationState): boolean { const latestInvalidationState = this.lookupInvalidationState( prevInvalidationState.dataIDs, ); const currentInvalidations = latestInvalidationState.invalidations; const prevInvalidations = prevInvalidationState.invalidations; // Check if global invalidation has changed if ( currentInvalidations.get('global') !== prevInvalidations.get('global') ) { return true; } // Check if the invalidation state for any of the ids has changed. for (const dataID of prevInvalidationState.dataIDs) { if (currentInvalidations.get(dataID) !== prevInvalidations.get(dataID)) { return true; } } return false; } subscribeToInvalidationState( invalidationState: InvalidationState, callback: () => void, ): Disposable { const subscription = {callback, invalidationState}; const dispose = () => { this._invalidationSubscriptions.delete(subscription); }; this._invalidationSubscriptions.add(subscription); return {dispose}; } _updateInvalidationSubscription( subscription: InvalidationSubscription, invalidatedStore: boolean, ) { const {callback, invalidationState} = subscription; const {dataIDs} = invalidationState; const isSubscribedToInvalidatedIDs = invalidatedStore || dataIDs.some(dataID => this._invalidatedRecordIDs.has(dataID)); if (!isSubscribedToInvalidatedIDs) { return; } callback(); } snapshot(): void { invariant( this._optimisticSource == null, 'RelayModernStore: Unexpected call to snapshot() while a previous ' + 'snapshot exists.', ); const log = this.__log; if (log != null) { log({ name: 'store.snapshot', }); } this._storeSubscriptions.snapshotSubscriptions(this.getSource()); if (this._gcRun) { this._gcRun = null; this._shouldScheduleGC = true; } this._optimisticSource = RelayOptimisticRecordSource.create( this.getSource(), ); } restore(): void { const optimisticSource = this._optimisticSource; invariant( optimisticSource, 'RelayModernStore: Unexpected call to restore(), expected a snapshot ' + 'to exist (make sure to call snapshot()).', ); const log = this.__log; if (log != null) { log({ name: 'store.restore', }); } const optimisticIDs = RelayOptimisticRecordSource.getOptimisticRecordIDs(optimisticSource); // Clean up any LiveResolver subscriptions made while in the optimistic // state. this._resolverCache.unsubscribeFromLiveResolverRecords(optimisticIDs); this._optimisticSource = null; if (this._shouldScheduleGC) { this.scheduleGC(); } this._storeSubscriptions.restoreSubscriptions(); this._resolverCache.invalidateResolverRecords(optimisticIDs); } scheduleGC() { if (this._gcHoldCounter > 0) { this._shouldScheduleGC = true; return; } if (this._gcRun) { return; } this._gcRun = this._collect(); this._gcScheduler(this._gcStep); } /** * Run a full GC synchronously. */ __gc(): void { // Don't run GC while there are optimistic updates applied if (this._optimisticSource != null) { return; } const gcRun = this._collect(); while (!gcRun.next().done) {} } _gcStep = () => { if (this._gcRun) { if (this._gcRun.next().done) { this._gcRun = null; } else { this._gcScheduler(this._gcStep); } } }; *_collect(): Generator<void, void, void> { /* eslint-disable no-labels */ const log = this.__log; top: while (true) { if (log != null) { log({ name: 'store.gc.start', }); } const startEpoch = this._currentWriteEpoch; const references = new Set<DataID>(); // Mark all records that are traversable from a root for (const {operation} of this._roots.values()) { const selector = operation.root; RelayReferenceMarker.mark( this._recordSource, selector, references, this._operationLoader, this._shouldProcessClientComponents, ); // Yield for other work after each operation yield; // If the store was updated, restart if (startEpoch !== this._currentWriteEpoch) { if (log != null) { log({ name: 'store.gc.interrupted', }); } continue top; } } // Sweep records without references if (references.size === 0) { // Short-circuit if *nothing* is referenced this._recordSource.clear(); } else { // Evict any unreferenced nodes const storeIDs = this._recordSource.getRecordIDs(); for (let ii = 0; ii < storeIDs.length; ii++) { const dataID = storeIDs[ii]; if (!references.has(dataID)) { const record = this._recordSource.get(dataID); if (record != null) { const maybeResolverSubscription = RelayModernRecord.getValue( record, RELAY_RESOLVER_LIVE_STATE_SUBSCRIPTION_KEY, ); if (maybeResolverSubscription != null) { // $FlowFixMe - this value if it is not null, it is a function maybeResolverSubscription(); } } this._recordSource.remove(dataID); } } } if (log != null) { log({ name: 'store.gc.end', references, }); } return; } } // Internal API for normalizing @outputType payloads in LiveResolverCache. __getNormalizationOptions( path: $ReadOnlyArray<string>, ): NormalizationOptions { return { path, getDataID: this._getDataID, treatMissingFieldsAsNull: this._treatMissingFieldsAsNull, shouldProcessClientComponents: this._shouldProcessClientComponents, actorIdentifier: this._actorIdentifier, }; } // Internal API that can be only invoked from the LiveResolverCache // to notify subscribers of `updatedRecords`. __notifyUpdatedSubscribers(updatedRecords: UpdatedRecords): void { const nextUpdatedRecordIDs = getUpdatedDataIDs(updatedRecords); const prevUpdatedRecordIDs = this._updatedRecordIDs; this._updatedRecordIDs = nextUpdatedRecordIDs; this.notify(); this._updatedRecordIDs = prevUpdatedRecordIDs; } } function initializeRecordSource(target: MutableRecordSource) { if (!target.has(ROOT_ID)) { const rootRecord = RelayModernRecord.create(ROOT_ID, ROOT_TYPE); target.set(ROOT_ID, rootRecord); } } /** * Updates the target with information from source, also updating a mapping of * which records in the target were changed as a result. * Additionally, will mark records as invalidated at the current write epoch * given the set of record ids marked as stale in this update. */ function updateTargetFromSource( target: MutableRecordSource, source: RecordSource, currentWriteEpoch: number, idsMarkedForInvalidation: ?DataIDSet, updatedRecordIDs: DataIDSet, invalidatedRecordIDs: DataIDSet, ): void { // First, update any records that were marked for invalidation. // For each provided dataID that was invalidated, we write the // INVALIDATED_AT_KEY on the record, indicating // the epoch at which the record was invalidated. if (idsMarkedForInvalidation) { idsMarkedForInvalidation.forEach(dataID => { const targetRecord = target.get(dataID); const sourceRecord = source.get(dataID); // If record was deleted during the update (and also invalidated), // we don't need to count it as an invalidated id if (sourceRecord === null) { return; } let nextRecord; if (targetRecord != null) { // If the target record exists, use it to set the epoch // at which it was invalidated. This record will be updated with // any changes from source in the section below // where we update the target records based on the source. nextRecord = RelayModernRecord.clone(targetRecord); } else { // If the target record doesn't exist, it means that a new record // in the source was created (and also invalidated), so we use that // record to set the epoch at which it was invalidated. This record // will be updated with any changes from source in the section below // where we update the target records based on the source. nextRecord = sourceRecord != null ? RelayModernRecord.clone(sourceRecord) : null; } if (!nextRecord) { return; } RelayModernRecord.setValue( nextRecord, RelayStoreUtils.INVALIDATED_AT_KEY, currentWriteEpoch, ); invalidatedRecordIDs.add(dataID); target.set(dataID, nextRecord); }); } // Update the target based on the changes present in source const dataIDs = source.getRecordIDs(); for (let ii = 0; ii < dataIDs.length; ii++) { const dataID = dataIDs[ii]; const sourceRecord = source.get(dataID); const targetRecord = target.get(dataID); // Prevent mutation of a record from outside the store. if (__DEV__) { if (sourceRecord) { RelayModernRecord.freeze(sourceRecord); } } if (sourceRecord && targetRecord) { const nextRecord = RelayModernRecord.update(targetRecord, sourceRecord); if (nextRecord !== targetRecord) { // Prevent mutation of a record from outside the store. if (__DEV__) { RelayModernRecord.freeze(nextRecord); } updatedRecordIDs.add(dataID); target.set(dataID, nextRecord); } } else if (sourceRecord === null) { target.delete(dataID); if (targetRecord !== null) { updatedRecordIDs.add(dataID); } } else if (sourceRecord) { target.set(dataID, sourceRecord); updatedRecordIDs.add(dataID); } // don't add explicit undefined } } /** * Returns an OperationAvailability given the Availability returned * by checking an operation, and when that operation was last written to the store. * Specifically, the provided Availability of an operation will contain the * value of when a record referenced by the operation was most recently * invalidated; given that value, and given when this operation was last * written to the store, this function will return the overall * OperationAvailability for the operation. */ function getAvailabilityStatus( operationAvailability: Availability, operationLastWrittenAt: ?number, operationFetchTime: ?number, queryCacheExpirationTime: ?number, ): OperationAvailability { const {mostRecentlyInvalidatedAt, status} = operationAvailability; if (typeof mostRecentlyInvalidatedAt === 'number') { // If some record referenced by this operation is stale, then the operation itself is stale // if either the operation itself was never written *or* the operation was last written // before the most recent invalidation of its reachable records. if ( operationLastWrittenAt == null || mostRecentlyInvalidatedAt > operationLastWrittenAt ) { return {status: 'stale'}; } } if (status === 'missing') { return {status: 'missing'}; } if (operationFetchTime != null && queryCacheExpirationTime != null) { const isStale = operationFetchTime <= Date.now() - queryCacheExpirationTime; if (isStale) { return {status: 'stale'}; } } // There were no invalidations of any reachable records *or* the operation is known to have // been fetched after the most recent record invalidation. return {status: 'available', fetchTime: operationFetchTime ?? null}; } module.exports = RelayModernStore;