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;
}
}