API Docs for: v3.16.0-alpha.2
Show:

File: ../store/addon/-private/system/fetch-manager.ts

import { default as RSVP, Promise } from 'rsvp';
import { DEBUG } from '@glimmer/env';
import { run as emberRunLoop } from '@ember/runloop';
import { assert, warn } from '@ember/debug';
import Snapshot from './snapshot';
import { guardDestroyedStore, _guard, _bind, _objectIsAlive } from './store/common';
import { normalizeResponseHelper } from './store/serializer-response';
import coerceId from './coerce-id';
import { A } from '@ember/array';
import RequestCache from './request-cache';
import { CollectionResourceDocument, SingleResourceDocument } from '../ts-interfaces/ember-data-json-api';
import { RecordIdentifier } from '../ts-interfaces/identifier';
import { FindRecordQuery, SaveRecordMutation, Request } from '../ts-interfaces/fetch-manager';
import { symbol } from '../ts-interfaces/utils/symbol';
import CoreStore from './core-store';
import { errorsArrayToHash } from './errors-utils';

function payloadIsNotBlank(adapterPayload): boolean {
  if (Array.isArray(adapterPayload)) {
    return true;
  } else {
    return Object.keys(adapterPayload || {}).length !== 0;
  }
}

const emberRun = emberRunLoop.backburner;
export const SaveOp: unique symbol = symbol('SaveOp');

interface PendingFetchItem {
  identifier: RecordIdentifier;
  queryRequest: Request;
  resolver: RSVP.Deferred<any>;
  options: { [k: string]: unknown };
  trace?: any;
}

interface PendingSaveItem {
  resolver: RSVP.Deferred<any>;
  snapshot: Snapshot;
  identifier: RecordIdentifier;
  options: { [k: string]: unknown; [SaveOp]: 'createRecord' | 'saveRecord' | 'updateRecord' };
  queryRequest: Request;
}

export default class FetchManager {
  isDestroyed: boolean;
  requestCache: RequestCache;
  // saves which are pending in the runloop
  _pendingSave: PendingSaveItem[];
  // fetches pending in the runloop, waiting to be coalesced
  _pendingFetch: Map<string, PendingFetchItem[]>;

  constructor(private _store: CoreStore) {
    // used to keep track of all the find requests that need to be coalesced
    this._pendingFetch = new Map();
    this._pendingSave = [];
    this.requestCache = new RequestCache();
  }

  /**
    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.
 */
  scheduleSave(identifier: RecordIdentifier, options: any = {}): RSVP.Promise<null | SingleResourceDocument> {
    let promiseLabel = 'DS: Model#save ' + this;
    let resolver = RSVP.defer<null | SingleResourceDocument>(promiseLabel);
    let query: SaveRecordMutation = {
      op: 'saveRecord',
      recordIdentifier: identifier,
      options,
    };

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

    let snapshot = new Snapshot(options, identifier, this._store);
    let pendingSaveItem = {
      snapshot: snapshot,
      resolver: resolver,
      identifier,
      options,
      queryRequest,
    };
    this._pendingSave.push(pendingSaveItem);
    emberRun.scheduleOnce('actions', this, this._flushPendingSaves);

    this.requestCache.enqueue(resolver.promise, pendingSaveItem.queryRequest);

    return resolver.promise;
  }

  _flushPendingSave(pending: PendingSaveItem) {
    let { snapshot, resolver, identifier, options } = pending;
    let adapter = this._store.adapterFor(identifier.type);
    let operation = options[SaveOp];

    let internalModel = snapshot._internalModel;
    let modelName = snapshot.modelName;
    let store = this._store;
    let 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.resolve().then(() => adapter[operation](store, modelClass, snapshot));
    let serializer = store.serializerFor(modelName);
    let label = `DS: Extract and notify about ${operation} completion of ${internalModel}`;

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

    promise = guardDestroyedStore(promise, store, label);
    promise = _guard(promise, _bind(_objectIsAlive, internalModel));

    promise = promise.then(
      adapterPayload => {
        if (adapterPayload) {
          return normalizeResponseHelper(serializer, store, modelClass, adapterPayload, snapshot.id, operation);
        }
      },
      function(error) {
        if (error && error.isAdapterError === true && error.code === 'InvalidError') {
          let parsedErrors = error.errors;

          if (typeof serializer.extractErrors === 'function') {
            parsedErrors = serializer.extractErrors(store, modelClass, error, snapshot.id);
          } else {
            parsedErrors = errorsArrayToHash(error.errors);
          }

          throw { error, parsedErrors };
        } else {
          throw { error };
        }
      },
      label
    );
    resolver.resolve(promise);
  }

  /**
    This method is called at the end of the run loop, and
    flushes any records passed into `scheduleSave`

    @method flushPendingSave
    @private
  */
  _flushPendingSaves() {
    let pending = this._pendingSave.slice();
    this._pendingSave = [];
    for (let i = 0, j = pending.length; i < j; i++) {
      let pendingItem = pending[i];
      this._flushPendingSave(pendingItem);
    }
  }

  scheduleFetch(identifier: RecordIdentifier, options: any, shouldTrace: boolean): RSVP.Promise<any> {
    // TODO Probably the store should pass in the query object

    let query: FindRecordQuery = {
      op: 'findRecord',
      recordIdentifier: identifier,
      options,
    };

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

    let pendingFetches = this._pendingFetch.get(identifier.type);

    // We already have a pending fetch for this
    if (pendingFetches) {
      let matchingPendingFetch = pendingFetches.find(fetch => fetch.identifier.id === identifier.id);
      if (matchingPendingFetch) {
        return matchingPendingFetch.resolver.promise;
      }
    }

    let id = identifier.id;
    let modelName = identifier.type;

    let resolver = RSVP.defer(`Fetching ${modelName}' with id: ${id}`);
    let pendingFetchItem: PendingFetchItem = {
      identifier,
      resolver,
      options,
      queryRequest,
    };

    if (DEBUG) {
      if (shouldTrace) {
        let trace;

        try {
          throw new Error(`Trace Origin for scheduled fetch for ${modelName}:${id}.`);
        } catch (e) {
          trace = e;
        }

        // enable folks to discover the origin of this findRecord call when
        // debugging. Ideally we would have a tracked queue for requests with
        // labels or local IDs that could be used to merge this trace with
        // the trace made available when we detect an async leak
        pendingFetchItem.trace = trace;
      }
    }

    let promise = resolver.promise;

    if (this._pendingFetch.size === 0) {
      emberRun.schedule('actions', this, this.flushAllPendingFetches);
    }

    let fetches = this._pendingFetch;

    if (!fetches.has(modelName)) {
      fetches.set(modelName, []);
    }

    (fetches.get(modelName) as PendingFetchItem[]).push(pendingFetchItem);

    this.requestCache.enqueue(promise, pendingFetchItem.queryRequest);
    return promise;
  }

  _fetchRecord(fetchItem: PendingFetchItem) {
    let identifier = fetchItem.identifier;
    let modelName = identifier.type;
    let adapter = this._store.adapterFor(modelName);

    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'
    );

    let snapshot = new Snapshot(fetchItem.options, identifier, this._store);
    let klass = this._store.modelFor(identifier.type);

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

    let id = identifier.id;

    let label = `DS: Handle Adapter#findRecord of '${modelName}' with id: '${id}'`;

    promise = guardDestroyedStore(promise, this._store, label);
    promise = promise.then(
      adapterPayload => {
        assert(
          `You made a 'findRecord' request for a '${modelName}' with id '${id}', but the adapter's response did not have any data`,
          !!payloadIsNotBlank(adapterPayload)
        );
        let serializer = this._store.serializerFor(modelName);
        let payload = normalizeResponseHelper(serializer, this._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)
        );

        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;
      },
      error => {
        throw error;
      },
      `DS: Extract payload of '${modelName}'`
    );

    fetchItem.resolver.resolve(promise);
  }

  // TODO should probably refactor expectedSnapshots to be identifiers
  handleFoundRecords(
    seeking: { [id: string]: PendingFetchItem },
    coalescedPayload: CollectionResourceDocument,
    expectedSnapshots: Snapshot[]
  ) {
    // resolve found records
    let found = Object.create(null);
    let payloads = coalescedPayload.data;
    let coalescedIncluded = coalescedPayload.included || [];
    for (let i = 0, l = payloads.length; i < l; i++) {
      let payload = payloads[i];
      let pair = seeking[payload.id];
      found[payload.id] = payload;
      let included = coalescedIncluded.concat(payloads);

      // TODO remove original data from included
      if (pair) {
        let resolver = pair.resolver;
        resolver.resolve({ data: payload, included });
      }
    }

    // reject missing records

    // TODO NOW clean this up to refer to payloads
    let missingSnapshots: Snapshot[] = [];

    for (let i = 0, l = expectedSnapshots.length; i < l; i++) {
      let snapshot = expectedSnapshots[i];

      if (!found[snapshot.id]) {
        missingSnapshots.push(snapshot);
      }
    }

    if (missingSnapshots.length) {
      warn(
        'Ember Data expected to find records with the following ids in the adapter response but they were missing: [ "' +
          missingSnapshots.map(r => r.id).join('", "') +
          '" ]',
        false,
        {
          id: 'ds.store.missing-records-from-adapter',
        }
      );
      this.rejectFetchedItems(seeking, missingSnapshots);
    }
  }

  rejectFetchedItems(seeking: { [id: string]: PendingFetchItem }, snapshots: Snapshot[], error?) {
    for (let i = 0, l = snapshots.length; i < l; i++) {
      let identifier = snapshots[i];
      let pair = seeking[identifier.id];

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

  _findMany(
    adapter: any,
    store: CoreStore,
    modelName: string,
    snapshots: Snapshot[],
    identifiers: RecordIdentifier[],
    optionsMap
  ) {
    let modelClass = store.modelFor(modelName); // `adapter.findMany` gets the modelClass still
    let ids = snapshots.map(s => s.id);
    let promise = adapter.findMany(store, modelClass, ids, A(snapshots));
    let label = `DS: Handle Adapter#findMany of '${modelName}'`;

    if (promise === undefined) {
      throw new Error('adapter.findMany returned undefined, this was very likely a mistake');
    }

    promise = guardDestroyedStore(promise, store, label);

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

  _processCoalescedGroup(
    seeking: { [id: string]: PendingFetchItem },
    group: Snapshot[],
    adapter: any,
    optionsMap,
    modelName: string
  ) {
    //TODO check what happened with identifiers here
    let totalInGroup = group.length;
    let ids = new Array(totalInGroup);
    let groupedSnapshots = new Array(totalInGroup);

    for (let j = 0; j < totalInGroup; j++) {
      groupedSnapshots[j] = group[j];
      ids[j] = groupedSnapshots[j].id;
    }

    let store = this._store;
    if (totalInGroup > 1) {
      this._findMany(adapter, store, modelName, group, groupedSnapshots, optionsMap)
        .then(payloads => {
          this.handleFoundRecords(seeking, payloads, groupedSnapshots);
        })
        .catch(error => {
          this.rejectFetchedItems(seeking, groupedSnapshots, error);
        });
    } else if (ids.length === 1) {
      let pair = seeking[groupedSnapshots[0].id];
      this._fetchRecord(pair);
    } else {
      assert("You cannot return an empty array from adapter's method groupRecordsForFindMany", false);
    }
  }

  _flushPendingFetchForType(pendingFetchItems: PendingFetchItem[], modelName: string) {
    let adapter = this._store.adapterFor(modelName);
    let shouldCoalesce = !!adapter.findMany && adapter.coalesceFindRequests;
    let totalItems = pendingFetchItems.length;
    let identifiers = new Array(totalItems);
    let seeking: { [id: string]: PendingFetchItem } = Object.create(null);

    let optionsMap = new WeakMap<RecordIdentifier, Object>();

    for (let i = 0; i < totalItems; i++) {
      let pendingItem = pendingFetchItems[i];
      let identifier = pendingItem.identifier;
      identifiers[i] = identifier;
      optionsMap.set(identifier, pendingItem.options);
      seeking[identifier.id as string] = pendingItem;
    }

    if (shouldCoalesce) {
      // TODO: Improve records => snapshots => records => snapshots
      //
      // We want to provide records to all store methods and snapshots to all
      // adapter methods. To make sure we're doing that we're providing an array
      // of snapshots to adapter.groupRecordsForFindMany(), which in turn will
      // return grouped snapshots instead of grouped records.
      //
      // But since the _findMany() finder is a store method we need to get the
      // records from the grouped snapshots even though the _findMany() finder
      // will once again convert the records to snapshots for adapter.findMany()
      let snapshots = new Array<Snapshot>(totalItems);
      for (let i = 0; i < totalItems; i++) {
        let options = optionsMap.get(identifiers[i]);
        snapshots[i] = new Snapshot(options, identifiers[i], this._store);
      }

      let groups: Snapshot[][] = adapter.groupRecordsForFindMany(this, snapshots);

      for (let i = 0, l = groups.length; i < l; i++) {
        this._processCoalescedGroup(seeking, groups[i], adapter, optionsMap, modelName);
      }
    } else {
      for (let i = 0; i < totalItems; i++) {
        this._fetchRecord(pendingFetchItems[i]);
      }
    }
  }

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

    this._pendingFetch.forEach(this._flushPendingFetchForType, this);
    this._pendingFetch.clear();
  }

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