API Docs for: 5.4.0-alpha.27+524e8e8a
Show:

File: ../packages/legacy-compat/src/legacy-network-handler/fetch-manager.ts

import { assert, warn } from '@ember/debug';

import { importSync } from '@embroider/macros';

import { DEBUG, TESTING } from '@ember-data/env';
import { HAS_GRAPH_PACKAGE } from '@ember-data/packages';
import { createDeferred } from '@ember-data/request';
import type { Deferred } from '@ember-data/request/-private/types';
import type Store from '@ember-data/store';
import { coerceId } from '@ember-data/store/-private';
import type { StoreRequestInfo } from '@ember-data/store/-private/cache-handler';
import type { InstanceCache } from '@ember-data/store/-private/caches/instance-cache';
import type RequestStateService from '@ember-data/store/-private/network/request-cache';
import type { FindRecordQuery, Request, SaveRecordMutation } from '@ember-data/store/-private/network/request-cache';
import type { ModelSchema } from '@ember-data/store/-types/q/ds-model';
import type { FindOptions } from '@ember-data/store/-types/q/store';
import type { StableExistingRecordIdentifier, StableRecordIdentifier } from '@warp-drive/core-types/identifier';
import type { CollectionResourceDocument, SingleResourceDocument } from '@warp-drive/core-types/spec/raw';

import { upgradeStore } from '../-private';
import { assertIdentifierHasId } from './identifier-has-id';
import { payloadIsNotBlank } from './legacy-data-utils';
import type { AdapterPayload, MinimumAdapterInterface } from './minimum-adapter-interface';
import type { MinimumSerializerInterface } from './minimum-serializer-interface';
import { normalizeResponseHelper } from './serializer-response';
import Snapshot from './snapshot';

type AdapterErrors = Error & { errors?: string[]; isAdapterError?: true };
type SerializerWithParseErrors = MinimumSerializerInterface & {
  extractErrors?(store: Store, modelClass: ModelSchema, error: AdapterErrors, recordId: string | null): unknown;
};

export const SaveOp: unique symbol = Symbol('SaveOp');

export type FetchMutationOptions = FindOptions & { [SaveOp]: 'createRecord' | 'deleteRecord' | 'updateRecord' };

interface PendingFetchItem {
  identifier: StableExistingRecordIdentifier;
  queryRequest: Request;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  resolver: Deferred<any>;
  options: FindOptions;
  trace?: unknown;
  promise: Promise<StableExistingRecordIdentifier>;
}

interface PendingSaveItem {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  resolver: Deferred<any>;
  snapshot: Snapshot;
  identifier: StableRecordIdentifier;
  options: FetchMutationOptions;
  queryRequest: Request;
}

export default class FetchManager {
  declare isDestroyed: boolean;
  declare requestCache: RequestStateService;
  // fetches pending in the runloop, waiting to be coalesced
  declare _pendingFetch: Map<string, Map<StableExistingRecordIdentifier, PendingFetchItem[]>>;
  declare _store: Store;

  constructor(store: Store) {
    this._store = store;
    // used to keep track of all the find requests that need to be coalesced
    this._pendingFetch = new Map();
    this.requestCache = store.getRequestStateService();
    this.isDestroyed = false;
  }

  createSnapshot(identifier: StableRecordIdentifier, options: FindOptions = {}): Snapshot {
    return new Snapshot(options, identifier, this._store);
  }

  /**
    This method is called by `record.save`, and gets passed a
    resolver for the promise that `record.save` returns.

    It schedules saving to happen at the end of the run loop.

    @internal
  */
  scheduleSave(
    identifier: StableRecordIdentifier,
    options: FetchMutationOptions
  ): Promise<null | SingleResourceDocument> {
    const resolver = createDeferred<SingleResourceDocument | null>();
    const query: SaveRecordMutation = {
      op: 'saveRecord',
      recordIdentifier: identifier,
      options,
    };

    const queryRequest: Request = {
      data: [query],
    };

    const snapshot = this.createSnapshot(identifier, options);
    const pendingSaveItem: PendingSaveItem = {
      snapshot: snapshot,
      resolver: resolver,
      identifier,
      options,
      queryRequest,
    };

    const monitored = this.requestCache._enqueue(resolver.promise, pendingSaveItem.queryRequest);
    _flushPendingSave(this._store, pendingSaveItem);

    return monitored;
  }

  scheduleFetch(
    identifier: StableExistingRecordIdentifier,
    options: FindOptions,
    request: StoreRequestInfo
  ): Promise<StableExistingRecordIdentifier> {
    const query: FindRecordQuery = {
      op: 'findRecord',
      recordIdentifier: identifier,
      options,
    };

    const queryRequest: Request = {
      data: [query],
    };

    const pendingFetch = this.getPendingFetch(identifier, options);
    if (pendingFetch) {
      return pendingFetch;
    }

    const modelName = identifier.type;

    const resolver = createDeferred<SingleResourceDocument>();
    const pendingFetchItem: PendingFetchItem = {
      identifier,
      resolver,
      options,
      queryRequest,
    } as PendingFetchItem;

    const resolverPromise = resolver.promise;
    const store = this._store;
    const isInitialLoad = !store._instanceCache.recordIsLoaded(identifier); // we don't use isLoading directly because we are the request

    const monitored = this.requestCache._enqueue(resolverPromise, pendingFetchItem.queryRequest);
    let promise = monitored.then(
      (payload) => {
        // ensure that regardless of id returned we assign to the correct record
        if (payload.data && !Array.isArray(payload.data)) {
          payload.data.lid = identifier.lid;
        }

        // additional data received in the payload
        // may result in the merging of identifiers (and thus records)
        const potentiallyNewIm = store._push(payload, options.reload);
        if (potentiallyNewIm && !Array.isArray(potentiallyNewIm)) {
          return potentiallyNewIm;
        }

        return identifier;
      },
      (error) => {
        assert(`Async Leak Detected: Expected the store to not be destroyed`, !store.isDestroyed);
        const cache = store.cache;
        if (!cache || cache.isEmpty(identifier) || isInitialLoad) {
          let isReleasable = true;
          if (HAS_GRAPH_PACKAGE) {
            if (!cache) {
              const graphFor = (importSync('@ember-data/graph/-private') as typeof import('@ember-data/graph/-private'))
                .graphFor;
              const graph = graphFor(store);
              isReleasable = graph.isReleasable(identifier);
              if (!isReleasable) {
                graph.unload(identifier, true);
              }
            }
          }
          if (cache || isReleasable) {
            store._enableAsyncFlush = true;
            store._instanceCache.unloadRecord(identifier);
            store._enableAsyncFlush = null;
          }
        }
        throw error;
      }
    );

    if (this._pendingFetch.size === 0) {
      void new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
        this.flushAllPendingFetches();
      });
    }

    const fetchesByType = this._pendingFetch;
    let fetchesById = fetchesByType.get(modelName);

    if (!fetchesById) {
      fetchesById = new Map();
      fetchesByType.set(modelName, fetchesById);
    }

    let requestsForIdentifier = fetchesById.get(identifier);
    if (!requestsForIdentifier) {
      requestsForIdentifier = [];
      fetchesById.set(identifier, requestsForIdentifier);
    }

    requestsForIdentifier.push(pendingFetchItem);

    if (TESTING) {
      if (!request.disableTestWaiter) {
        const { waitForPromise } = importSync('@ember/test-waiters') as {
          waitForPromise: <T>(promise: Promise<T>) => Promise<T>;
        };
        promise = waitForPromise(promise);
      }
    }

    pendingFetchItem.promise = promise;
    return promise;
  }

  getPendingFetch(identifier: StableExistingRecordIdentifier, options: FindOptions) {
    const pendingFetches = this._pendingFetch.get(identifier.type)?.get(identifier);

    // We already have a pending fetch for this
    if (pendingFetches) {
      const matchingPendingFetch = pendingFetches.find((fetch) => isSameRequest(options, fetch.options));
      if (matchingPendingFetch) {
        return matchingPendingFetch.promise;
      }
    }
  }

  flushAllPendingFetches() {
    if (this.isDestroyed) {
      return;
    }

    const store = this._store;
    this._pendingFetch.forEach((fetchItem, type) => _flushPendingFetchForType(store, fetchItem, type));
    this._pendingFetch.clear();
  }

  fetchDataIfNeededForIdentifier(
    identifier: StableExistingRecordIdentifier,
    options: FindOptions = {},
    request: StoreRequestInfo
  ): Promise<StableExistingRecordIdentifier> {
    // pre-loading will change the isEmpty value
    const isEmpty = _isEmpty(this._store._instanceCache, identifier);
    const isLoading = _isLoading(this._store._instanceCache, identifier);

    let promise: Promise<StableExistingRecordIdentifier>;
    if (isEmpty) {
      assertIdentifierHasId(identifier);

      if (DEBUG) {
        promise = this.scheduleFetch(identifier, Object.assign({}, options, { reload: true }), request);
      } else {
        options.reload = true;
        promise = this.scheduleFetch(identifier, options, request);
      }
    } else if (isLoading) {
      promise = this.getPendingFetch(identifier, options)!;
      assert(`Expected to find a pending request for a record in the loading state, but found none`, promise);
    } else {
      promise = Promise.resolve(identifier);
    }

    return promise;
  }

  destroy() {
    this.isDestroyed = true;
  }
}

function _isEmpty(instanceCache: InstanceCache, identifier: StableRecordIdentifier): boolean {
  const cache = instanceCache.cache;
  if (!cache) {
    return true;
  }
  const isNew = cache.isNew(identifier);
  const isDeleted = cache.isDeleted(identifier);
  const isEmpty = cache.isEmpty(identifier);

  return (!isNew || isDeleted) && isEmpty;
}

function _isLoading(cache: InstanceCache, identifier: StableRecordIdentifier): boolean {
  const req = cache.store.getRequestStateService();
  // const fulfilled = req.getLastRequestForRecord(identifier);
  const isLoaded = cache.recordIsLoaded(identifier);

  return (
    !isLoaded &&
    // fulfilled === null &&
    req.getPendingRequestsForRecord(identifier).some((r) => r.type === 'query')
  );
}

function includesSatisfies(current: undefined | string | string[], existing: undefined | string | string[]): boolean {
  // if we have no includes we are good
  if (!current?.length) {
    return true;
  }

  // if we are here we have includes,
  // and if existing has no includes then we will need a new request
  if (!existing?.length) {
    return false;
  }

  const arrCurrent = (Array.isArray(current) ? current : current.split(',')).sort();
  const arrExisting = (Array.isArray(existing) ? existing : existing.split(',')).sort();

  // includes are identical
  if (arrCurrent.join(',') === arrExisting.join(',')) {
    return true;
  }

  // if all of current includes are in existing includes then we are good
  // so if we find one that is not in existing then we need a new request
  for (let i = 0; i < arrCurrent.length; i++) {
    if (!arrExisting.includes(arrCurrent[i])) {
      return false;
    }
  }

  return true;
}

function optionsSatisfies(current: object | undefined, existing: object | undefined): boolean {
  return !current || current === existing || Object.keys(current).length === 0;
}

// this function helps resolve whether we have a pending request that we should use instead
function isSameRequest(options: FindOptions = {}, existingOptions: FindOptions = {}) {
  return (
    optionsSatisfies(options.adapterOptions, existingOptions.adapterOptions) &&
    includesSatisfies(options.include, existingOptions.include)
  );
}

function _findMany(
  store: Store,
  adapter: MinimumAdapterInterface,
  modelName: string,
  snapshots: Snapshot[]
): Promise<CollectionResourceDocument> {
  const modelClass = store.modelFor(modelName); // `adapter.findMany` gets the modelClass still
  const promise = Promise.resolve().then(() => {
    const ids = snapshots.map((s) => s.id!);
    assert(
      `Cannot fetch a record without an id`,
      ids.every((v) => v !== null)
    );
    // eslint-disable-next-line @typescript-eslint/unbound-method
    assert(`Expected this adapter to implement findMany for coalescing`, adapter.findMany);
    const ret = adapter.findMany(store, modelClass, ids, snapshots);
    assert('adapter.findMany returned undefined, this was very likely a mistake', ret !== undefined);
    return ret;
  });
  upgradeStore(store);

  return promise.then((adapterPayload) => {
    assert(
      `You made a 'findMany' request for '${modelName}' records with ids '[${snapshots
        .map((s) => s.id!)
        .join(',')}]', but the adapter's response did not have any data`,
      !!payloadIsNotBlank(adapterPayload)
    );
    const serializer = store.serializerFor(modelName);
    const payload = normalizeResponseHelper(serializer, store, modelClass, adapterPayload, null, 'findMany');
    return payload as CollectionResourceDocument;
  });
}

function rejectFetchedItems(fetchMap: Map<Snapshot, PendingFetchItem>, snapshots: Snapshot[], error?: Error) {
  for (let i = 0, l = snapshots.length; i < l; i++) {
    const snapshot = snapshots[i];
    const pair = fetchMap.get(snapshot);

    if (pair) {
      pair.resolver.reject(
        error ||
          new Error(
            `Expected: '<${
              snapshot.modelName
            }:${snapshot.id!}>' to be present in the adapter provided payload, but it was not found.`
          )
      );
    }
  }
}

function handleFoundRecords(
  store: Store,
  fetchMap: Map<Snapshot, PendingFetchItem>,
  snapshots: Snapshot[],
  coalescedPayload: CollectionResourceDocument
) {
  /*
    It is possible that the same ID is included multiple times
    via multiple snapshots. This happens when more than one
    options hash was supplied, each of which must be uniquely
    accounted for.

    However, since we can't map from response to a specific
    options object, we resolve all snapshots by id with
    the first response we see.
  */
  const snapshotsById = new Map<string, Snapshot[]>();
  for (let i = 0; i < snapshots.length; i++) {
    const id = snapshots[i].id!;
    let snapshotGroup = snapshotsById.get(id);
    if (!snapshotGroup) {
      snapshotGroup = [];
      snapshotsById.set(id, snapshotGroup);
    }
    snapshotGroup.push(snapshots[i]);
  }

  const included = Array.isArray(coalescedPayload.included) ? coalescedPayload.included : [];

  // resolve found records
  const resources = coalescedPayload.data;
  for (let i = 0, l = resources.length; i < l; i++) {
    const resource = resources[i];
    const snapshotGroup = snapshotsById.get(resource.id);
    snapshotsById.delete(resource.id);

    if (!snapshotGroup) {
      // TODO consider whether this should be a deprecation/assertion
      included.push(resource);
    } else {
      snapshotGroup.forEach((snapshot) => {
        const pair = fetchMap.get(snapshot)!;
        const resolver = pair.resolver;
        resolver.resolve({ data: resource });
      });
    }
  }

  if (included.length > 0) {
    store._push({ data: null, included }, true);
  }

  if (snapshotsById.size === 0) {
    return;
  }

  // reject missing records
  const rejected: Snapshot[] = [];
  snapshotsById.forEach((snapshotArray) => {
    rejected.push(...snapshotArray);
  });
  warn(
    'Ember Data expected to find records with the following ids in the adapter response from findMany but they were missing: [ "' +
      [...snapshotsById.values()].map((r) => r[0].id).join('", "') +
      '" ]',
    {
      id: 'ds.store.missing-records-from-adapter',
    }
  );

  rejectFetchedItems(fetchMap, rejected);
}

function _fetchRecord(store: Store, adapter: MinimumAdapterInterface, fetchItem: PendingFetchItem) {
  upgradeStore(store);
  const identifier = fetchItem.identifier;
  const modelName = identifier.type;

  assert(`You tried to find a record but you have no adapter (for ${modelName})`, adapter);
  assert(
    `You tried to find a record but your adapter (for ${modelName}) does not implement 'findRecord'`,
    typeof adapter.findRecord === 'function'
  );

  const snapshot = store._fetchManager.createSnapshot(identifier, fetchItem.options);
  const klass = store.modelFor(identifier.type);
  const id = identifier.id;

  let promise = Promise.resolve().then(() => {
    return adapter.findRecord(store, klass, identifier.id, snapshot);
  });

  promise = promise.then((adapterPayload) => {
    assert(`Async Leak Detected: Expected the store to not be destroyed`, !(store.isDestroyed || store.isDestroying));
    assert(
      `You made a 'findRecord' request for a '${modelName}' with id '${id}', but the adapter's response did not have any data`,
      !!payloadIsNotBlank(adapterPayload)
    );
    const serializer = store.serializerFor(modelName);
    const payload = normalizeResponseHelper(serializer, store, klass, adapterPayload, id, 'findRecord');
    assert(
      `Ember Data expected the primary data returned from a 'findRecord' response to be an object but instead it found an array.`,
      !Array.isArray(payload.data)
    );
    assert(
      `The 'findRecord' request for ${modelName}:${id} resolved indicating success but contained no primary data. To indicate a 404 not found you should either reject the promise returned by the adapter's findRecord method or throw a NotFoundError.`,
      'data' in payload && payload.data !== null && typeof payload.data === 'object'
    );

    warn(
      `You requested a record of type '${modelName}' with id '${id}' but the adapter returned a payload with primary data having an id of '${payload.data.id}'. Use 'store.findRecord()' when the requested id is the same as the one returned by the adapter. In other cases use 'store.queryRecord()' instead.`,
      coerceId(payload.data.id) === coerceId(id),
      {
        id: 'ds.store.findRecord.id-mismatch',
      }
    );

    return payload;
  }) as Promise<AdapterPayload>;

  fetchItem.resolver.resolve(promise);
}

function _processCoalescedGroup(
  store: Store,
  fetchMap: Map<Snapshot, PendingFetchItem>,
  group: Snapshot[],
  adapter: MinimumAdapterInterface,
  modelName: string
) {
  if (group.length > 1) {
    _findMany(store, adapter, modelName, group)
      .then((payloads: CollectionResourceDocument) => {
        handleFoundRecords(store, fetchMap, group, payloads);
      })
      .catch((error: Error) => {
        rejectFetchedItems(fetchMap, group, error);
      });
  } else if (group.length === 1) {
    _fetchRecord(store, adapter, fetchMap.get(group[0])!);
  } else {
    assert("You cannot return an empty array from adapter's method groupRecordsForFindMany", false);
  }
}

function _flushPendingFetchForType(
  store: Store,
  pendingFetchMap: Map<StableExistingRecordIdentifier, PendingFetchItem[]>,
  modelName: string
) {
  upgradeStore(store);
  const adapter = store.adapterFor(modelName);
  const shouldCoalesce = !!adapter.findMany && adapter.coalesceFindRequests;

  if (shouldCoalesce) {
    const pendingFetchItems: PendingFetchItem[] = [];
    pendingFetchMap.forEach((requestsForIdentifier, identifier) => {
      if (requestsForIdentifier.length > 1) {
        return;
      }

      // remove this entry from the map so it's not processed again
      pendingFetchMap.delete(identifier);
      pendingFetchItems.push(requestsForIdentifier[0]);
    });

    const totalItems = pendingFetchItems.length;

    if (totalItems > 1) {
      const snapshots = new Array<Snapshot>(totalItems);
      const fetchMap = new Map<Snapshot, PendingFetchItem>();
      for (let i = 0; i < totalItems; i++) {
        const fetchItem = pendingFetchItems[i];
        snapshots[i] = store._fetchManager.createSnapshot(fetchItem.identifier, fetchItem.options);
        fetchMap.set(snapshots[i], fetchItem);
      }

      let groups: Snapshot[][];
      if (adapter.groupRecordsForFindMany) {
        groups = adapter.groupRecordsForFindMany(store, snapshots);
      } else {
        groups = [snapshots];
      }

      for (let i = 0, l = groups.length; i < l; i++) {
        _processCoalescedGroup(store, fetchMap, groups[i], adapter, modelName);
      }
    } else if (totalItems === 1) {
      _fetchRecord(store, adapter, pendingFetchItems[0]);
    }
  }

  pendingFetchMap.forEach((pendingFetchItems) => {
    pendingFetchItems.forEach((pendingFetchItem) => {
      _fetchRecord(store, adapter, pendingFetchItem);
    });
  });
}

function _flushPendingSave(store: Store, pending: PendingSaveItem) {
  const { snapshot, resolver, identifier, options } = pending;
  upgradeStore(store);
  const adapter = store.adapterFor(identifier.type);
  const operation = options[SaveOp];

  const modelName = snapshot.modelName;
  const modelClass = store.modelFor(modelName);

  assert(`You tried to update a record but you have no adapter (for ${modelName})`, adapter);
  assert(
    `You tried to update a record but your adapter (for ${modelName}) does not implement '${operation}'`,
    typeof adapter[operation] === 'function'
  );

  let promise: Promise<AdapterPayload> = Promise.resolve().then(() => adapter[operation](store, modelClass, snapshot));
  const serializer: SerializerWithParseErrors | null = store.serializerFor(modelName);

  assert(
    `Your adapter's '${operation}' method must return a value, but it returned 'undefined'`,
    promise !== undefined
  );

  promise = promise.then((adapterPayload) => {
    if (adapterPayload) {
      return normalizeResponseHelper(serializer, store, modelClass, adapterPayload, snapshot.id, operation);
    }
  }) as Promise<AdapterPayload>;

  resolver.resolve(promise);
}