From 143e1b2a19a45e3209a7c0ffe3b2a87646124fdb Mon Sep 17 00:00:00 2001 From: Igor Terzic Date: Mon, 22 Jul 2019 05:54:10 -0700 Subject: [PATCH] Implement request state service --- package.json | 2 +- .../relationships/belongs-to-test.js | 1 + .../record-data/record-data-test.ts | 13 + .../integration/request-state-service-test.ts | 230 +++++++++ packages/-ember-data/tsconfig.json | 4 +- packages/adapter/tsconfig.json | 11 +- .../canary-features/addon/default-features.js | 1 + packages/canary-features/addon/index.js | 1 + .../addon/-private/system/fetch-manager.ts | 481 ++++++++++++++++++ .../-private/system/model/internal-model.ts | 160 ++++-- .../addon/-private/system/model/model.js | 98 +++- .../addon/-private/system/model/states.js | 12 +- .../relationships/state/relationship.ts | 27 +- .../addon/-private/system/request-cache.ts | 132 +++++ .../store/addon/-private/system/snapshot.js | 5 +- packages/store/addon/-private/system/store.ts | 254 ++++++--- .../addon/-private/system/store/finders.js | 4 + .../addon/-private/ts-interfaces/ds-model.ts | 7 + .../-private/ts-interfaces/fetch-manager.ts | 40 ++ .../-private/ts-interfaces/promise-proxies.ts | 3 + .../addon/-private/utils/promise-record.ts | 9 +- yarn.lock | 2 +- 22 files changed, 1329 insertions(+), 168 deletions(-) create mode 100644 packages/-ember-data/tests/integration/request-state-service-test.ts create mode 100644 packages/store/addon/-private/system/fetch-manager.ts create mode 100644 packages/store/addon/-private/system/request-cache.ts create mode 100644 packages/store/addon/-private/ts-interfaces/ds-model.ts create mode 100644 packages/store/addon/-private/ts-interfaces/fetch-manager.ts create mode 100644 packages/store/addon/-private/ts-interfaces/promise-proxies.ts diff --git a/package.json b/package.json index 59bf7a90ca5..a10ab5582ff 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@types/ember-qunit": "^3.4.6", "@types/ember-test-helpers": "~1.0.5", "@types/ember-testing-helpers": "~0.0.3", - "@types/ember__debug": "^3.0.3", + "@types/ember__debug": "3.0.4", "@types/ember__test-helpers": "~0.7.8", "@types/qunit": "^2.5.3", "@types/rsvp": "^4.0.3", diff --git a/packages/-ember-data/tests/acceptance/relationships/belongs-to-test.js b/packages/-ember-data/tests/acceptance/relationships/belongs-to-test.js index 7109c0112c6..1e840d86ee4 100644 --- a/packages/-ember-data/tests/acceptance/relationships/belongs-to-test.js +++ b/packages/-ember-data/tests/acceptance/relationships/belongs-to-test.js @@ -193,6 +193,7 @@ module('async belongs-to rendering tests', function(hooks) { hooks.beforeEach(function() { let { owner } = this; owner.register('model:person', Person); + owner.register('model:pet', Pet); owner.register('adapter:application', TestAdapter); owner.register( 'serializer:application', diff --git a/packages/-ember-data/tests/integration/record-data/record-data-test.ts b/packages/-ember-data/tests/integration/record-data/record-data-test.ts index 65617baaee1..2e3b50c4e34 100644 --- a/packages/-ember-data/tests/integration/record-data/record-data-test.ts +++ b/packages/-ember-data/tests/integration/record-data/record-data-test.ts @@ -77,6 +77,12 @@ class TestRecordData { removeFromInverseRelationships(isNew: boolean) {} _initRecordCreateOptions(options) {} + isNew() { + return false; + } + isDeleted() { + return false; + } } let CustomStore = Store.extend({ @@ -194,6 +200,7 @@ module('integration/record-data - Custom RecordData Implementations', function(h let calledUnloadRecord = 0; let calledRollbackAttributes = 0; let calledDidCommit = 0; + let isNew = false; class LifecycleRecordData extends TestRecordData { pushData(data, calculateChange?: boolean) { @@ -202,6 +209,7 @@ module('integration/record-data - Custom RecordData Implementations', function(h clientDidCreate() { calledClientDidCreate++; + isNew = true; } willCommit() { @@ -222,6 +230,11 @@ module('integration/record-data - Custom RecordData Implementations', function(h didCommit(data) { calledDidCommit++; + isNew = false; + } + + isNew() { + return isNew; } } diff --git a/packages/-ember-data/tests/integration/request-state-service-test.ts b/packages/-ember-data/tests/integration/request-state-service-test.ts new file mode 100644 index 00000000000..fe7a7a450bc --- /dev/null +++ b/packages/-ember-data/tests/integration/request-state-service-test.ts @@ -0,0 +1,230 @@ +import { setupTest } from 'ember-qunit'; +import Model from 'ember-data/model'; +import Store from 'ember-data/store'; +import { module, test } from 'qunit'; +import { identifierCacheFor } from '@ember-data/store/-private'; +import EmberObject from '@ember/object'; +import { attr } from '@ember-data/model'; +import { REQUEST_SERVICE } from '@ember-data/canary-features'; +import { RequestStateEnum } from '@ember-data/store/-private/ts-interfaces/fetch-manager'; + +class Person extends Model { + // TODO fix the typing for naked attrs + @attr('string', {}) + name; + + @attr('string', {}) + lastName; +} + +if (REQUEST_SERVICE) { + module('integration/request-state-service - Request State Service', function(hooks) { + setupTest(hooks); + + let store: Store; + + hooks.beforeEach(function() { + let { owner } = this; + owner.register('model:person', Person); + store = owner.lookup('service:store'); + }); + + test('getPendingRequest and getLastRequest return correct inflight and fulfilled requests', async function(assert) { + assert.expect(10); + + let normalizedHash = { + data: { + type: 'person', + id: '1', + lid: '', + attributes: { + name: 'Scumbag Dale', + }, + relationships: {}, + }, + included: [], + }; + + let { owner } = this; + + let TestAdapter = EmberObject.extend({ + findRecord() { + const personHash = { + type: 'person', + id: '1', + name: 'Scumbag Dale', + }; + + return Promise.resolve(personHash); + }, + deleteRecord() { + return Promise.resolve(); + }, + + updateRecord() { + return Promise.resolve(); + }, + + createRecord() { + return Promise.resolve(); + }, + }); + + owner.register('adapter:application', TestAdapter); + + store = owner.lookup('service:store'); + + let promise = store.findRecord('person', '1'); + let requestService = store.getRequestStateService(); + + // Relying on sequential lids until identifiers land + let identifier = identifierCacheFor(store).getOrCreateRecordIdentifier({ type: 'person', id: '1' }); + normalizedHash.data.lid = identifier.lid; + let request = requestService.getPendingRequestsForRecord(identifier)[0]; + + assert.equal(request.state, 'pending', 'request is pending'); + assert.equal(request.type, 'query', 'request is a query'); + let requestOp = { + op: 'findRecord', + recordIdentifier: identifier, + options: {}, + }; + assert.deepEqual(request.request.data[0], requestOp, 'request op is correct'); + + let person = await promise; + let lastRequest = requestService.getLastRequestForRecord(identifier); + let requestStateResult = { + type: 'query' as const, + state: 'fulfilled' as RequestStateEnum, + request: { data: [requestOp] }, + response: { data: normalizedHash }, + }; + assert.deepEqual(lastRequest, requestStateResult, 'request is correct after fulfilling'); + assert.deepEqual( + requestService.getPendingRequestsForRecord(identifier).length, + 0, + 'no pending requests remaining' + ); + + let savingPromise = person.save(); + let savingRequest = requestService.getPendingRequestsForRecord(identifier)[0]; + + assert.equal(savingRequest.state, 'pending', 'request is pending'); + assert.equal(savingRequest.type, 'mutation', 'request is a mutation'); + let savingRequestOp = { + op: 'saveRecord', + recordIdentifier: identifier, + options: {}, + }; + assert.deepEqual(savingRequest.request.data[0], savingRequestOp, 'request op is correct'); + + await savingPromise; + let lastSavingRequest = requestService.getLastRequestForRecord(identifier); + let savingRequestStateResult = { + type: 'mutation' as const, + state: 'fulfilled' as RequestStateEnum, + request: { data: [savingRequestOp] }, + response: { data: undefined }, + }; + assert.deepEqual(lastSavingRequest, savingRequestStateResult, 'request is correct after fulfilling'); + assert.deepEqual( + requestService.getPendingRequestsForRecord(identifier).length, + 0, + 'no pending requests remaining' + ); + }); + + test('can subscribe to events for an identifier', async function(assert) { + assert.expect(9); + + const personHash = { + type: 'person', + id: '1', + name: 'Scumbag Dale', + }; + + let normalizedHash = { + data: { + type: 'person', + id: '1', + attributes: { + name: 'Scumbag Dale', + }, + relationships: {}, + }, + included: [], + }; + + let { owner } = this; + + let TestAdapter = EmberObject.extend({ + findRecord() { + return Promise.resolve(personHash); + }, + deleteRecord() { + return Promise.resolve(); + }, + + updateRecord() { + return Promise.resolve(); + }, + + createRecord() { + return Promise.resolve(); + }, + }); + + owner.register('adapter:application', TestAdapter, { singleton: false }); + + store = owner.lookup('service:store'); + + let requestService = store.getRequestStateService(); + // Relying on sequential lids until identifiers land + let identifier = identifierCacheFor(store).getOrCreateRecordIdentifier({ type: 'person', id: '1' }); + let count = 0; + let requestOp = { + op: 'findRecord', + recordIdentifier: identifier, + options: {}, + }; + let savingRequestOp = { + op: 'saveRecord', + recordIdentifier: identifier, + options: {}, + }; + + let unsubToken = requestService.subscribeForRecord(identifier, request => { + if (count === 0) { + assert.equal(request.state, 'pending', 'request is pending'); + assert.equal(request.type, 'query', 'request is a query'); + assert.deepEqual(request.request.data[0], requestOp, 'request op is correct'); + } else if (count === 1) { + let requestStateResult = { + type: 'query' as const, + state: 'fulfilled' as RequestStateEnum, + request: { data: [requestOp] }, + response: { data: normalizedHash }, + }; + assert.deepEqual(request, requestStateResult, 'request is correct after fulfilling'); + } else if (count === 2) { + assert.equal(request.state, 'pending', 'request is pending'); + assert.equal(request.type, 'mutation', 'request is a mutation'); + assert.deepEqual(request.request.data[0], savingRequestOp, 'request op is correct'); + } else if (count === 3) { + let savingRequestStateResult = { + type: 'mutation' as const, + state: 'fulfilled' as RequestStateEnum, + request: { data: [savingRequestOp] }, + response: { data: undefined }, + }; + assert.deepEqual(request, savingRequestStateResult, 'request is correct after fulfilling'); + } + count++; + }); + + let person = await store.findRecord('person', '1'); + await person.save(); + assert.equal(count, 4, 'callback called four times'); + }); + }); +} diff --git a/packages/-ember-data/tsconfig.json b/packages/-ember-data/tsconfig.json index 40f2755786d..702b2d6c05d 100644 --- a/packages/-ember-data/tsconfig.json +++ b/packages/-ember-data/tsconfig.json @@ -19,10 +19,10 @@ "dummy/*": ["tests/dummy/app/*", "app/*"], "ember-data": ["addon"], "ember-data/*": ["addon/*"], - "ember-data/test-support": ["addon-test-support"], - "ember-data/test-support/*": ["addon-test-support/*"], "@ember-data/store": ["../store/addon"], "@ember-data/store/*": ["../store/addon/*"], + "ember-data/test-support": ["addon-test-support"], + "ember-data/test-support/*": ["addon-test-support/*"], "@ember-data/adapter/error": ["../adapter/addon/error"], "@ember-data/canary-features": ["../canary-features/addon"], "*": ["../store/types/*"] diff --git a/packages/adapter/tsconfig.json b/packages/adapter/tsconfig.json index 72e5477cf94..846a24382e4 100644 --- a/packages/adapter/tsconfig.json +++ b/packages/adapter/tsconfig.json @@ -17,8 +17,6 @@ "paths": { "dummy/tests/*": ["tests/*"], "dummy/*": ["tests/dummy/app/*", "app/*"], - "@ember-data/adapter": ["addon"], - "@ember-data/adapter/*": ["addon/*"], "@ember-data/adapter/test-support": ["addon-test-support"], "@ember-data/adapter/test-support/*": ["addon-test-support/*"], "ember-data": ["../-ember-data/addon"], @@ -28,13 +26,6 @@ "*": ["types/*"] } }, - "include": [ - "app/**/*", - "addon/**/*", - "tests/**/*", - "types/**/*", - "test-support/**/*", - "addon-test-support/**/*" - ], + "include": ["app/**/*", "addon/**/*", "tests/**/*", "types/**/*", "test-support/**/*", "addon-test-support/**/*"], "exclude": ["node_modules"] } diff --git a/packages/canary-features/addon/default-features.js b/packages/canary-features/addon/default-features.js index c574afc9ba3..c5f4798cc47 100644 --- a/packages/canary-features/addon/default-features.js +++ b/packages/canary-features/addon/default-features.js @@ -17,4 +17,5 @@ export default { RECORD_DATA_ERRORS: null, RECORD_DATA_STATE: null, IDENTIFIERS: null, + REQUEST_SERVICE: null, }; diff --git a/packages/canary-features/addon/index.js b/packages/canary-features/addon/index.js index 3da693ddb3b..5dd1f61ff1d 100644 --- a/packages/canary-features/addon/index.js +++ b/packages/canary-features/addon/index.js @@ -21,4 +21,5 @@ export const FEATURES = assign({}, DEFAULT_FEATURES, ENV.FEATURES); export const SAMPLE_FEATURE_FLAG = featureValue(FEATURES.SAMPLE_FEATURE_FLAG); export const RECORD_DATA_ERRORS = featureValue(FEATURES.RECORD_DATA_ERRORS); export const RECORD_DATA_STATE = featureValue(FEATURES.RECORD_DATA_STATE); +export const REQUEST_SERVICE = featureValue(FEATURES.REQUEST_SERVICE); export const IDENTIFIERS = featureValue(FEATURES.IDENTIFIERS); diff --git a/packages/store/addon/-private/system/fetch-manager.ts b/packages/store/addon/-private/system/fetch-manager.ts new file mode 100644 index 00000000000..3956811f639 --- /dev/null +++ b/packages/store/addon/-private/system/fetch-manager.ts @@ -0,0 +1,481 @@ +import { default as RSVP, Promise } from 'rsvp'; +import { DEBUG } from '@glimmer/env'; +import { run as emberRunLoop } from '@ember/runloop'; +import { assert, warn, inspect } from '@ember/debug'; +import Snapshot from './snapshot'; +import { guardDestroyedStore, _guard, _bind, _objectIsAlive } from './store/common'; +import { normalizeResponseHelper } from './store/serializer-response'; +import { serializerForAdapter } from './store/serializers'; +import { InvalidError } from '@ember-data/adapter/error'; +import coerceId from './coerce-id'; +import { A } from '@ember/array'; + +import { _findHasMany, _findBelongsTo, _findAll, _query, _queryRecord } from './store/finders'; +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 Store from './store'; +import recordDataFor from './record-data-for'; + +function payloadIsNotBlank(adapterPayload): boolean { + if (Array.isArray(adapterPayload)) { + return true; + } else { + return Object.keys(adapterPayload || {}).length !== 0; + } +} + +const emberRun = emberRunLoop.backburner; +export const SaveOp = Symbol('SaveOp'); + +interface PendingFetchItem { + identifier: RecordIdentifier; + queryRequest: Request; + resolver: RSVP.Deferred; + options: { [k: string]: unknown }; + trace?: any; +} + +interface PendingSaveItem { + resolver: RSVP.Deferred; + 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; + + constructor(private _store: Store) { + // 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 { + let promiseLabel = 'DS: Model#save ' + this; + let resolver = RSVP.defer(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 recordData = recordDataFor(this._store._internalModelForResource(identifier)); + + 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 = serializerForAdapter(store, adapter, 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 => { + let payload, data, sideloaded; + if (adapterPayload) { + payload = normalizeResponseHelper(serializer, store, modelClass, adapterPayload, snapshot.id, operation); + return payload; + } + }, + function(error) { + if (error instanceof InvalidError) { + let parsedErrors = serializer.extractErrors(store, modelClass, error, snapshot.id); + 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 { + // 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 = serializerForAdapter(this._store, adapter, 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 https://emberjs.com/api/data/classes/DS.Store.html#method_queryRecord`, + 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: Store, + 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 = serializerForAdapter(store, adapter, 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(); + + 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(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; + } +} diff --git a/packages/store/addon/-private/system/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 02eb6883710..2c26f4e3ad4 100644 --- a/packages/store/addon/-private/system/model/internal-model.ts +++ b/packages/store/addon/-private/system/model/internal-model.ts @@ -24,7 +24,7 @@ import RecordData from '../../ts-interfaces/record-data'; import { JsonApiResource, JsonApiValidationError } from '../../ts-interfaces/record-data-json-api'; import { Record } from '../../ts-interfaces/record'; import { Dict } from '../../ts-interfaces/utils'; -import { IDENTIFIERS, RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-features'; +import { IDENTIFIERS, RECORD_DATA_ERRORS, RECORD_DATA_STATE, REQUEST_SERVICE } from '@ember-data/canary-features'; import { identifierCacheFor } from '../../identifiers/cache'; import { StableRecordIdentifier } from '../../ts-interfaces/identifier'; import { internalModelFactoryFor, setRecordIdentifier } from '../store/internal-model-factory'; @@ -344,10 +344,13 @@ export default class InternalModel { store, _internalModel: this, currentState: this.currentState, - isError: this.isError, - adapterError: this.error, }; + if (!REQUEST_SERVICE) { + createOptions.isError = this.isError; + createOptions.adapterError = this.error; + } + if (properties !== undefined) { assert( `You passed '${properties}' as properties for record creation instead of an object.`, @@ -490,8 +493,12 @@ export default class InternalModel { let promiseLabel = 'DS: Model#save ' + this; let resolver = RSVP.defer(promiseLabel); - this.store.scheduleSave(this, resolver, options); - return resolver.promise; + if (REQUEST_SERVICE) { + return this.store.scheduleSave(this, resolver, options); + } else { + this.store.scheduleSave(this, resolver, options); + return resolver.promise; + } } startedReloading() { @@ -519,28 +526,54 @@ export default class InternalModel { } reload(options) { - this.startedReloading(); - let internalModel = this; - let promiseLabel = 'DS: Model#reload of ' + this; - - return new Promise(function(resolve) { - internalModel.send('reloadRecord', { resolve, options }); - }, promiseLabel) - .then( - function() { - internalModel.didCleanError(); - return internalModel; - }, - function(error) { - internalModel.didError(error); - throw error; - }, - 'DS: Model#reload complete, update flags' - ) - .finally(function() { - internalModel.finishedReloading(); - internalModel.updateRecordArrays(); - }); + if (REQUEST_SERVICE) { + if (!options) { + options = {}; + } + this.startedReloading(); + let internalModel = this; + let promiseLabel = 'DS: Model#reload of ' + this; + + return internalModel.store + ._reloadRecord(internalModel, options) + .then( + function() { + //TODO NOW seems like we shouldn't need to do this + return internalModel; + }, + function(error) { + throw error; + }, + 'DS: Model#reload complete, update flags' + ) + .finally(function() { + internalModel.finishedReloading(); + internalModel.updateRecordArrays(); + }); + } else { + this.startedReloading(); + let internalModel = this; + let promiseLabel = 'DS: Model#reload of ' + this; + + return new Promise(function(resolve) { + internalModel.send('reloadRecord', { resolve, options }); + }, promiseLabel) + .then( + function() { + internalModel.didCleanError(); + return internalModel; + }, + function(error) { + internalModel.didError(error); + throw error; + }, + 'DS: Model#reload complete, update flags' + ) + .finally(function() { + internalModel.finishedReloading(); + internalModel.updateRecordArrays(); + }); + } } /* @@ -918,7 +951,7 @@ export default class InternalModel { @private */ createSnapshot(options) { - return new Snapshot(this, options); + return new Snapshot(options || {}, this.identifier, this.store); } /* @@ -926,8 +959,12 @@ export default class InternalModel { @private @param {Promise} promise */ - loadingData(promise) { - this.send('loadingData', promise); + loadingData(promise?) { + if (REQUEST_SERVICE) { + this.send('loadingData'); + } else { + this.send('loadingData', promise); + } } /* @@ -955,9 +992,16 @@ export default class InternalModel { } hasChangedAttributes() { - if (this.isLoading() && !this.isReloading) { - // no need to instantiate _recordData in this case - return false; + if (REQUEST_SERVICE) { + if (!this.__recordData) { + // no need to calculate changed attributes when calling `findRecord` + return false; + } + } else { + if (this.isLoading() && !this.isReloading) { + // no need to calculate changed attributes when calling `findRecord` + return false; + } } return this._recordData.hasChangedAttributes(); } @@ -970,9 +1014,16 @@ export default class InternalModel { @private */ changedAttributes() { - if (this.isLoading() && !this.isReloading) { - // no need to calculate changed attributes when calling `findRecord` - return {}; + if (REQUEST_SERVICE) { + if (!this.__recordData) { + // no need to calculate changed attributes when calling `findRecord` + return {}; + } + } else { + if (this.isLoading() && !this.isReloading) { + // no need to calculate changed attributes when calling `findRecord` + return {}; + } } return this._recordData.changedAttributes(); } @@ -1100,6 +1151,7 @@ export default class InternalModel { let pivotName = extractPivotName(name); let state = this.currentState; + let oldState = state; let transitionMapId = `${state.stateName}->${name}`; do { @@ -1289,26 +1341,30 @@ export default class InternalModel { } didError(error) { - this.error = error; - this.isError = true; - - if (this.hasRecord) { - this._record.setProperties({ - isError: true, - adapterError: error, - }); + if (!REQUEST_SERVICE) { + this.error = error; + this.isError = true; + + if (this.hasRecord) { + this._record.setProperties({ + isError: true, + adapterError: error, + }); + } } } didCleanError() { - this.error = null; - this.isError = false; - - if (this.hasRecord) { - this._record.setProperties({ - isError: false, - adapterError: null, - }); + if (!REQUEST_SERVICE) { + this.error = null; + this.isError = false; + + if (this.hasRecord) { + this._record.setProperties({ + isError: false, + adapterError: null, + }); + } } } diff --git a/packages/store/addon/-private/system/model/model.js b/packages/store/addon/-private/system/model/model.js index 1fae555ba26..68a66734ff3 100644 --- a/packages/store/addon/-private/system/model/model.js +++ b/packages/store/addon/-private/system/model/model.js @@ -17,8 +17,10 @@ import recordDataFor from '../record-data-for'; import Ember from 'ember'; import InternalModel from './internal-model'; import RootState from './states'; -import { RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-features'; +import { RECORD_DATA_ERRORS, RECORD_DATA_STATE, REQUEST_SERVICE } from '@ember-data/canary-features'; import coerceId from '../coerce-id'; +import { recordIdentifierFor } from '@ember-data/store'; +import { InvalidError } from '@ember-data/adapter/error'; const { changeProperties } = Ember; @@ -97,6 +99,43 @@ if (RECORD_DATA_STATE) { isNewCP = retrieveFromCurrentState; } +let adapterError; +if (REQUEST_SERVICE) { + adapterError = computed(function() { + let request = this._lastError; + if (!request) { + return null; + } + return request.state === 'rejected' && request.response.data; + }); +} else { + adapterError = null; +} + +let isError; +if (REQUEST_SERVICE) { + isError = computed(function() { + let errorReq = this._errorRequests[this._errorRequests.length - 1]; + if (!errorReq) { + return false; + } else { + return true; + } + }); +} else { + isError = false; +} + +let isReloading; +if (REQUEST_SERVICE) { + isReloading = computed(function() { + let requests = this.store.getRequestStateService().getPendingRequestsForRecord(recordIdentifierFor(this)); + return !!requests.find(req => req.request.data[0].options.isReloading); + }); +} else { + isReloading = false; +} + /** The model class that all Ember Data records descend from. @@ -110,13 +149,47 @@ if (RECORD_DATA_STATE) { const Model = EmberObject.extend(DeprecatedEvented, { init() { this._super(...arguments); + + if (DEBUG) { + if (!this._internalModel) { + throw new EmberError( + 'You should not call `create` on a model. Instead, call `store.createRecord` with the attributes you would like to set.' + ); + } + } + if (RECORD_DATA_ERRORS) { this._invalidRequests = []; } + + if (REQUEST_SERVICE) { + this.store.getRequestStateService().subscribeForRecord(this._internalModel.identifier, request => { + if (request.state === 'rejected') { + // TODO filter out queries + this._lastError = request; + if (!(request.response && request.response.data instanceof InvalidError)) { + this._errorRequests.push(request); + } else { + this._invalidRequests.push(request); + } + } else if (request.state === 'fulfilled') { + this._invalidRequests = []; + this._errorRequests = []; + this._lastError = null; + } + this._notifyNetworkChanges(); + }); + this._errorRequests = []; + this._lastError = null; + } }, _notifyNetworkChanges: function() { - this.notifyPropertyChange('isValid'); + if (REQUEST_SERVICE) { + ['isSaving', 'isValid', 'isError', 'adapterError', 'isReloading'].forEach(key => this.notifyPropertyChange(key)); + } else { + ['isValid'].forEach(key => this.notifyPropertyChange(key)); + } }, /** @@ -336,7 +409,13 @@ const Model = EmberObject.extend(DeprecatedEvented, { @type {Boolean} @readOnly */ - isError: false, + isError: isError, + + _markErrorRequestAsClean() { + this._errorRequests = []; + this._lastError = null; + this._notifyNetworkChanges(); + }, /** If `true` the store is attempting to reload the record from the adapter. @@ -353,7 +432,7 @@ const Model = EmberObject.extend(DeprecatedEvented, { @type {Boolean} @readOnly */ - isReloading: false, + isReloading: isReloading, /** All ember models have an id property. This is an identifier @@ -508,7 +587,7 @@ const Model = EmberObject.extend(DeprecatedEvented, { @property adapterError @type {AdapterError} */ - adapterError: null, + adapterError: adapterError, /** Create a JSON representation of the record, using the serialization @@ -808,6 +887,9 @@ const Model = EmberObject.extend(DeprecatedEvented, { if (RECORD_DATA_ERRORS) { this._markInvalidRequestAsClean(); } + if (REQUEST_SERVICE) { + this._markErrorRequestAsClean(); + } }, /* @@ -1289,12 +1371,6 @@ if (DEBUG) { this._getDeprecatedEventedInfo = () => `${this._internalModel.modelName}#${this.id}`; } - if (!this._internalModel) { - throw new EmberError( - 'You should not call `create` on a model. Instead, call `store.createRecord` with the attributes you would like to set.' - ); - } - if (!isDefaultEmptyDescriptor(this, '_internalModel') || !(this._internalModel instanceof InternalModel)) { throw new Error( `'_internalModel' is a reserved property name on instances of classes extending Model. Please choose a different property name for ${this.constructor.toString()}` diff --git a/packages/store/addon/-private/system/model/states.js b/packages/store/addon/-private/system/model/states.js index fc3ebe213f1..45a81627496 100644 --- a/packages/store/addon/-private/system/model/states.js +++ b/packages/store/addon/-private/system/model/states.js @@ -2,7 +2,7 @@ @module @ember-data/store */ import { assert } from '@ember/debug'; - +import { REQUEST_SERVICE } from '@ember-data/canary-features'; /* This file encapsulates the various states that a record can transition through during its lifecycle. @@ -490,7 +490,9 @@ const RootState = { // EVENTS loadingData(internalModel, promise) { - internalModel._promiseProxy = promise; + if (!REQUEST_SERVICE) { + internalModel._promiseProxy = promise; + } internalModel.transitionTo('loading'); }, @@ -520,6 +522,8 @@ const RootState = { internalModel._promiseProxy = null; }, + loadingData() {}, + // EVENTS pushedData(internalModel) { internalModel.transitionTo('loaded.saved'); @@ -576,7 +580,9 @@ const RootState = { }, reloadRecord(internalModel, { resolve, options }) { - resolve(internalModel.store._reloadRecord(internalModel, options)); + if (!REQUEST_SERVICE) { + resolve(internalModel.store._reloadRecord(internalModel, options)); + } }, deleteRecord(internalModel) { diff --git a/packages/store/addon/-private/system/relationships/state/relationship.ts b/packages/store/addon/-private/system/relationships/state/relationship.ts index 9ec08383668..76ab9701cfa 100644 --- a/packages/store/addon/-private/system/relationships/state/relationship.ts +++ b/packages/store/addon/-private/system/relationships/state/relationship.ts @@ -198,9 +198,7 @@ export default class Relationship { } _hasSupportForImplicitRelationships(recordData: RelationshipRecordData): boolean { - return ( - recordData._implicitRelationships !== undefined && recordData._implicitRelationships !== null - ); + return recordData._implicitRelationships !== undefined && recordData._implicitRelationships !== null; } _hasSupportForRelationships(recordData: RelationshipRecordData): boolean { @@ -399,9 +397,7 @@ export default class Relationship { this._hasSupportForImplicitRelationships(recordData) && recordData._implicitRelationships[this.inverseKeyForImplicit] ) { - recordData._implicitRelationships[this.inverseKeyForImplicit].removeCanonicalRecordData( - this.recordData - ); + recordData._implicitRelationships[this.inverseKeyForImplicit].removeCanonicalRecordData(this.recordData); } } } @@ -426,9 +422,7 @@ export default class Relationship { this.isAsync ); } - recordData._implicitRelationships[this.inverseKeyForImplicit].addRecordData( - this.recordData - ); + recordData._implicitRelationships[this.inverseKeyForImplicit].addRecordData(this.recordData); } } } @@ -445,9 +439,7 @@ export default class Relationship { this._hasSupportForImplicitRelationships(recordData) && recordData._implicitRelationships[this.inverseKeyForImplicit] ) { - recordData._implicitRelationships[this.inverseKeyForImplicit].removeRecordData( - this.recordData - ); + recordData._implicitRelationships[this.inverseKeyForImplicit].removeRecordData(this.recordData); } } } @@ -562,18 +554,14 @@ export default class Relationship { updateLink(link: string | null) { warn( - `You pushed a record of type '${this.recordData.modelName}' with a relationship '${ - this.key - }' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload. EmberData will treat this relationship as known-to-be-empty.`, + `You pushed a record of type '${this.recordData.modelName}' with a relationship '${this.key}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload. EmberData will treat this relationship as known-to-be-empty.`, this.isAsync || this.hasAnyRelationshipData, { id: 'ds.store.push-link-for-sync-relationship', } ); assert( - `You have pushed a record of type '${this.recordData.modelName}' with '${ - this.key - }' as a link, but the value of that link is not a string.`, + `You have pushed a record of type '${this.recordData.modelName}' with '${this.key}' as a link, but the value of that link is not a string.`, typeof link === 'string' || link === null ); @@ -666,8 +654,7 @@ export default class Relationship { */ this.setHasFailedLoadAttempt(false); if (hasRelationshipDataProperty) { - let relationshipIsEmpty = - payload.data === null || (Array.isArray(payload.data) && payload.data.length === 0); + let relationshipIsEmpty = payload.data === null || (Array.isArray(payload.data) && payload.data.length === 0); this.setHasAnyRelationshipData(true); this.setRelationshipIsStale(false); diff --git a/packages/store/addon/-private/system/request-cache.ts b/packages/store/addon/-private/system/request-cache.ts new file mode 100644 index 00000000000..215c85e54db --- /dev/null +++ b/packages/store/addon/-private/system/request-cache.ts @@ -0,0 +1,132 @@ +import { RecordIdentifier } from '../ts-interfaces/identifier'; +import { + FindRecordQuery, + SaveRecordMutation, + Request, + RequestState, + Operation, + RequestStateEnum, +} from '../ts-interfaces/fetch-manager'; + +import { _findHasMany, _findBelongsTo, _findAll, _query, _queryRecord } from './store/finders'; + +const Touching = Symbol('touching'); +export const RequestPromise = Symbol('promise'); + +export interface InternalRequest extends RequestState { + [Touching]: RecordIdentifier[]; + [RequestPromise]?: Promise; +} + +type RecordOperation = FindRecordQuery | SaveRecordMutation; + +function hasRecordIdentifier(op: Operation): op is RecordOperation { + return 'recordIdentifier' in op; +} + +export default class RequestCache { + _pending: { [lid: string]: InternalRequest[] } = Object.create(null); + _done: { [lid: string]: InternalRequest[] } = Object.create(null); + _subscriptions: { [lid: string]: Function[] } = Object.create(null); + + enqueue(promise: Promise, queryRequest: Request) { + let query = queryRequest.data[0]; + if (hasRecordIdentifier(query)) { + let lid = query.recordIdentifier.lid; + let type = query.op === 'saveRecord' ? ('mutation' as const) : ('query' as const); + if (!this._pending[lid]) { + this._pending[lid] = []; + } + let request: InternalRequest = { + state: RequestStateEnum.pending, + request: queryRequest, + type, + [Touching]: [query.recordIdentifier], + [RequestPromise]: promise, + }; + this._pending[lid].push(request); + this._triggerSubscriptions(request); + promise.then( + result => { + this._dequeue(lid, request); + let finalizedRequest = { + state: RequestStateEnum.fulfilled, + request: queryRequest, + type, + [Touching]: request[Touching], + response: { data: result }, + }; + this._addDone(finalizedRequest); + this._triggerSubscriptions(finalizedRequest); + }, + error => { + this._dequeue(lid, request); + let finalizedRequest = { + state: RequestStateEnum.rejected, + request: queryRequest, + type, + [Touching]: request[Touching], + response: { data: error && error.error }, + }; + this._addDone(finalizedRequest); + this._triggerSubscriptions(finalizedRequest); + } + ); + } + } + + _triggerSubscriptions(req: InternalRequest) { + req[Touching].forEach(identifier => { + if (this._subscriptions[identifier.lid]) { + this._subscriptions[identifier.lid].forEach(callback => callback(req)); + } + }); + } + + _dequeue(lid: string, request: InternalRequest) { + this._pending[lid] = this._pending[lid].filter(req => req !== request); + } + + _addDone(request: InternalRequest) { + request[Touching].forEach(identifier => { + if (!this._done[identifier.lid]) { + this._done[identifier.lid] = []; + } + // TODO add support for multiple + let requestDataOp = request.request.data[0].op; + this._done[identifier.lid] = this._done[identifier.lid].filter(req => { + // TODO add support for multiple + let data; + if (req.request.data instanceof Array) { + data = req.request.data[0]; + } else { + data = req.request.data; + } + return data.op !== requestDataOp; + }); + this._done[identifier.lid].push(request); + }); + } + + subscribeForRecord(identifier: RecordIdentifier, callback: (requestState: RequestState) => void) { + if (!this._subscriptions[identifier.lid]) { + this._subscriptions[identifier.lid] = []; + } + this._subscriptions[identifier.lid].push(callback); + } + + getPendingRequestsForRecord(identifier: RecordIdentifier): RequestState[] { + if (this._pending[identifier.lid]) { + return this._pending[identifier.lid]; + } + return []; + } + + getLastRequestForRecord(identifier: RecordIdentifier): RequestState | null { + let requests = this._done[identifier.lid]; + if (requests) { + return requests[requests.length - 1]; + } + return null; + } +} diff --git a/packages/store/addon/-private/system/snapshot.js b/packages/store/addon/-private/system/snapshot.js index 63710d6b11f..a8ff2b68691 100644 --- a/packages/store/addon/-private/system/snapshot.js +++ b/packages/store/addon/-private/system/snapshot.js @@ -14,13 +14,14 @@ import { relationshipStateFor } from './record-data-for'; @param {Model} internalModel The model to create a snapshot from */ export default class Snapshot { - constructor(internalModel, options = {}) { + constructor(options, identifier, store) { this.__attributes = null; this._belongsToRelationships = Object.create(null); this._belongsToIds = Object.create(null); this._hasManyRelationships = Object.create(null); this._hasManyIds = Object.create(null); - this._internalModel = internalModel; + let internalModel = (this._internalModel = store._internalModelForResource(identifier)); + this._store = store; /* If the internalModel does not yet have a record, then we are diff --git a/packages/store/addon/-private/system/store.ts b/packages/store/addon/-private/system/store.ts index b5c08b9d252..4d7e6b07843 100644 --- a/packages/store/addon/-private/system/store.ts +++ b/packages/store/addon/-private/system/store.ts @@ -29,6 +29,7 @@ import { _bind, _guard, _objectIsAlive, guardDestroyedStore } from './store/comm import { normalizeResponseHelper } from './store/serializer-response'; import { serializerForAdapter } from './store/serializers'; import recordDataFor from './record-data-for'; +import FetchManager, { SaveOp } from './fetch-manager'; import { _find, _findMany, _findHasMany, _findBelongsTo, _findAll, _query, _queryRecord } from './store/finders'; @@ -37,7 +38,7 @@ import RecordArrayManager from './record-array-manager'; import InternalModel from './model/internal-model'; import RecordDataDefault from './model/record-data'; import edBackburner from './backburner'; -import { IDENTIFIERS, RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-features'; +import { IDENTIFIERS, RECORD_DATA_ERRORS, RECORD_DATA_STATE, REQUEST_SERVICE } from '@ember-data/canary-features'; import { Record } from '../ts-interfaces/record'; import promiseRecord from '../utils/promise-record'; @@ -57,6 +58,10 @@ import { ExistingResourceObject, } from '../ts-interfaces/ember-data-json-api'; import hasValidId from '../utils/has-valid-id'; +import { RequestPromise } from './request-cache'; +import { PromiseProxy } from '../ts-interfaces/promise-proxies'; +import { DSModel } from '../ts-interfaces/ds-model'; + const emberRun = emberRunLoop.backburner; const { ENV } = Ember; @@ -70,7 +75,7 @@ type PendingFetchItem = { }; type PendingSaveItem = { snapshot: Snapshot; - resolver: RSVP.Deferred; + resolver: RSVP.Deferred; }; let globalClientIdCounter = 1; @@ -197,6 +202,7 @@ class Store extends Service { // used to keep track of all the find requests that need to be coalesced private _pendingFetch = new Map(); + private _fetchManager: FetchManager; // DEBUG-only properties private _trackedAsyncRequests: AsyncTrackingToken[]; shouldAssertMethodCallsOnDestroyedStore: boolean = false; @@ -236,6 +242,10 @@ class Store extends Service { constructor() { super(...arguments); + if (REQUEST_SERVICE) { + this._fetchManager = new FetchManager(this); + } + if (DEBUG) { this.shouldAssertMethodCallsOnDestroyedStore = this.shouldAssertMethodCallsOnDestroyedStore || false; if (this.shouldTrackAsyncRequests === undefined) { @@ -290,6 +300,13 @@ class Store extends Service { } } + getRequestStateService() { + if (REQUEST_SERVICE) { + return this._fetchManager.requestCache; + } + throw 'RequestService is not available unless the feature flag is on and running on a canary build'; + } + /** This property returns the adapter, after resolving a possible string key. @@ -735,7 +752,7 @@ class Store extends Service { @param {Object} preload - optional set of attributes and relationships passed in either as IDs or as actual models @return {Promise} promise */ - findRecord(modelName: string, id: string | number, options?: any): Promise { + findRecord(modelName: string, id: string | number, options?: any): PromiseProxy { if (DEBUG) { assertDestroyingStore(this, 'findRecord'); } @@ -805,8 +822,14 @@ class Store extends Service { } //TODO double check about reloading - if (internalModel.isLoading()) { - return internalModel._promiseProxy; + if (!REQUEST_SERVICE) { + if (internalModel.isLoading()) { + return internalModel._promiseProxy; + } + } else { + if (internalModel.isLoading()) { + return this._scheduleFetch(internalModel, options); + } } return Promise.resolve(internalModel); @@ -876,64 +899,107 @@ class Store extends Service { return Promise.all(fetches); } - _scheduleFetch(internalModel: InternalModel, options): Promise { - if (internalModel._promiseProxy) { - return internalModel._promiseProxy; - } + _scheduleFetchThroughFetchManager(internalModel: InternalModel, options = {}): RSVP.Promise { + let generateStackTrace = this.generateStackTracesForTrackedRequests; + // TODO remove this once we dont rely on state machine + internalModel.loadingData(); + let identifier = internalModel.identifier; + let promise = this._fetchManager.scheduleFetch(internalModel.identifier, options, generateStackTrace); + return promise.then( + payload => { + if (IDENTIFIERS) { + // ensure that regardless of id returned we assign to the correct record + if (payload.data && !Array.isArray(payload.data)) { + payload.data.lid = identifier.lid; + } + } - let { id, modelName } = internalModel; - let resolver = defer(`Fetching ${modelName}' with id: ${id}`); - let pendingFetchItem: PendingFetchItem = { - internalModel, - resolver, - options, - }; + // Returning this._push here, breaks typing but not any tests, invesstigate potential missing tests + let potentiallyNewIm = this._push(payload); + if (potentiallyNewIm && !Array.isArray(potentiallyNewIm)) { + return potentiallyNewIm; + } else { + return internalModel; + } + }, + error => { + // TODO remove this once we dont rely on state machine + internalModel.notFound(); + if (internalModel.isEmpty()) { + internalModel.unloadRecord(); + } + throw error; + } + ); + } - if (DEBUG) { - if (this.generateStackTracesForTrackedRequests === true) { - let trace; + _scheduleFetch(internalModel: InternalModel, options): RSVP.Promise { + if (REQUEST_SERVICE) { + return this._scheduleFetchThroughFetchManager(internalModel, options); + } else { + if (internalModel._promiseProxy) { + return internalModel._promiseProxy; + } - try { - throw new Error(`Trace Origin for scheduled fetch for ${modelName}:${id}.`); - } catch (e) { - trace = e; - } + let { id, modelName } = internalModel; + let resolver = defer(`Fetching ${modelName}' with id: ${id}`); + let pendingFetchItem: PendingFetchItem = { + internalModel, + resolver, + options, + }; + + if (DEBUG) { + if (this.generateStackTracesForTrackedRequests === true) { + 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; + // 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; + let promise = resolver.promise; - internalModel.loadingData(promise); - if (this._pendingFetch.size === 0) { - emberRun.schedule('actions', this, this.flushAllPendingFetches); - } + internalModel.loadingData(promise); + if (this._pendingFetch.size === 0) { + emberRun.schedule('actions', this, this.flushAllPendingFetches); + } - let fetches = this._pendingFetch; - let pending = fetches.get(modelName); + let fetches = this._pendingFetch; + let pending = fetches.get(modelName); - if (pending === undefined) { - pending = []; - fetches.set(modelName, pending); - } + if (pending === undefined) { + pending = []; + fetches.set(modelName, pending); + } - pending.push(pendingFetchItem); + pending.push(pendingFetchItem); - return promise; + return promise; + } } flushAllPendingFetches() { - if (this.isDestroyed || this.isDestroying) { + if (REQUEST_SERVICE) { return; - } + //assert here + } else { + if (this.isDestroyed || this.isDestroying) { + return; + } - this._pendingFetch.forEach(this._flushPendingFetchForType, this); - this._pendingFetch.clear(); + this._pendingFetch.forEach(this._flushPendingFetchForType, this); + this._pendingFetch.clear(); + } } _flushPendingFetchForType(pendingFetchItems: PendingFetchItem[], modelName: string) { @@ -1186,7 +1252,10 @@ class Store extends Service { @param options optional to include adapterOptions @return {Promise} promise */ - _reloadRecord(internalModel, options) { + _reloadRecord(internalModel, options): RSVP.Promise { + if (REQUEST_SERVICE) { + options.isReloading = true; + } let { id, modelName } = internalModel; let adapter = this.adapterFor(modelName); @@ -1319,7 +1388,7 @@ class Store extends Service { return _findHasMany(adapter, this, internalModel, link, relationship, options); } - _findHasManyByJsonApiResource(resource, parentInternalModel, relationshipMeta, options): Promise { + _findHasManyByJsonApiResource(resource, parentInternalModel, relationshipMeta, options): RSVP.Promise { if (!resource) { return resolve([]); } @@ -1458,11 +1527,24 @@ class Store extends Service { relationshipIsStale || (!allInverseRecordsAreLoaded && !relationshipIsEmpty)); - // short circuit if we are already loading - if (internalModel && internalModel.isLoading()) { - return internalModel._promiseProxy.then(() => { - return internalModel.getRecord(); - }); + if (internalModel) { + // short circuit if we are already loading + if (REQUEST_SERVICE) { + // Temporary fix for requests already loading until we move this inside the fetch manager + let pendingRequests = this.getRequestStateService() + .getPendingRequestsForRecord(internalModel.identifier) + .filter(req => req.type === 'query'); + + if (pendingRequests.length > 0) { + return pendingRequests[0][RequestPromise].then(() => internalModel.getRecord()); + } + } else { + if (internalModel.isLoading()) { + return internalModel._promiseProxy.then(() => { + return internalModel.getRecord(); + }); + } + } } // fetch via link @@ -1555,7 +1637,7 @@ class Store extends Service { @param {Object} options optional, may include `adapterOptions` hash which will be passed to adapter.query @return {Promise} promise */ - query(modelName: string, query, options): PromiseArray { + query(modelName: string, query, options): PromiseArray { if (DEBUG) { assertDestroyingStore(this, 'query'); } @@ -1576,7 +1658,7 @@ class Store extends Service { return this._query(normalizedModelName, query, null, adapterOptionsWrapper); } - _query(modelName: string, query, array, options): PromiseArray { + _query(modelName: string, query, array, options): PromiseArray { assert(`You need to pass a model name to the store's query method`, isPresent(modelName)); assert(`You need to pass a query hash to the store's query method`, query); assert( @@ -2076,14 +2158,61 @@ class Store extends Service { @param {Resolver} resolver @param {Object} options */ - scheduleSave(internalModel: InternalModel, resolver: RSVP.Deferred, options) { + scheduleSave( + internalModel: InternalModel, + resolver: RSVP.Deferred, + options + ): void | RSVP.Promise { let snapshot = internalModel.createSnapshot(options); if (internalModel._isRecordFullyDeleted()) { resolver.resolve(); - return resolver.promise; + return resolver.promise as RSVP.Promise; } internalModel.adapterWillCommit(); + if (REQUEST_SERVICE) { + if (!options) { + options = {}; + } + let recordData = internalModel._recordData; + let operation: 'createRecord' | 'deleteRecord' | 'updateRecord' = 'updateRecord'; + + // TODO handle missing isNew + if (recordData.isNew && recordData.isNew()) { + operation = 'createRecord'; + } else if (recordData.isDeleted && recordData.isDeleted()) { + operation = 'deleteRecord'; + } + options[SaveOp] = operation; + + let fetchManagerPromise = this._fetchManager.scheduleSave(internalModel.identifier, options); + let promise = fetchManagerPromise.then( + payload => { + /* + Note to future spelunkers hoping to optimize. + We rely on this `run` to create a run loop if needed + that `store._push` and `store.didSaveRecord` will both share. + + We use `join` because it is often the case that we + have an outer run loop available still from the first + call to `store._push`; + */ + this._backburner.join(() => { + let data = payload && payload.data; + this.didSaveRecord(internalModel, { data }, operation); + if (payload && payload.included) { + this._push({ data: null, included: payload.included }); + } + }); + }, + ({ error, parsedErrors }) => { + this.recordWasInvalid(internalModel, parsedErrors, error); + throw error; + } + ); + + return promise; + } this._pendingSave.push({ snapshot: snapshot, resolver: resolver, @@ -2100,6 +2229,10 @@ class Store extends Service { @private */ flushPendingSave() { + if (REQUEST_SERVICE) { + // assert here + return; + } let pending = this._pendingSave.slice(); this._pendingSave = []; @@ -3031,8 +3164,9 @@ class Store extends Service { super.willDestroy(); this.recordArrayManager.destroy(); - this._adapterCache = null; - this._serializerCache = null; + // Check if we need to null this out + // this._adapterCache = null; + // this._serializerCache = null; identifierCacheFor(this).destroy(); diff --git a/packages/store/addon/-private/system/store/finders.js b/packages/store/addon/-private/system/store/finders.js index e0189610352..e617bbff31d 100644 --- a/packages/store/addon/-private/system/store/finders.js +++ b/packages/store/addon/-private/system/store/finders.js @@ -11,6 +11,7 @@ import { normalizeResponseHelper } from './serializer-response'; import { serializerForAdapter } from './serializers'; import { assign } from '@ember/polyfills'; import { IDENTIFIERS } from '@ember-data/canary-features'; +import { REQUEST_SERVICE } from '@ember-data/canary-features'; /** @module @ember-data/store @@ -25,6 +26,9 @@ function payloadIsNotBlank(adapterPayload) { } export function _find(adapter, store, modelClass, id, internalModel, options) { + if (REQUEST_SERVICE) { + // assert here + } let snapshot = internalModel.createSnapshot(options); let { modelName } = internalModel; let promise = Promise.resolve().then(() => { diff --git a/packages/store/addon/-private/ts-interfaces/ds-model.ts b/packages/store/addon/-private/ts-interfaces/ds-model.ts new file mode 100644 index 00000000000..acc71eda9bc --- /dev/null +++ b/packages/store/addon/-private/ts-interfaces/ds-model.ts @@ -0,0 +1,7 @@ +import { Record } from './record'; +import RSVP from 'rsvp'; + +// Placeholder until model.js is typed +export interface DSModel extends Record { + save(): RSVP.Promise; +} diff --git a/packages/store/addon/-private/ts-interfaces/fetch-manager.ts b/packages/store/addon/-private/ts-interfaces/fetch-manager.ts new file mode 100644 index 00000000000..cd9b3870743 --- /dev/null +++ b/packages/store/addon/-private/ts-interfaces/fetch-manager.ts @@ -0,0 +1,40 @@ +import { RecordIdentifier } from './identifier'; + +export interface Operation { + op: string; +} + +export interface FindRecordQuery extends Operation { + op: 'findRecord'; + recordIdentifier: RecordIdentifier; + options: any; +} + +export interface SaveRecordMutation extends Operation { + op: 'saveRecord'; + recordIdentifier: RecordIdentifier; + options: any; +} + +export interface Request { + data: Operation[]; + options?: any; +} + +export enum RequestStateEnum { + pending = 'pending', + fulfilled = 'fulfilled', + rejected = 'rejected', +} + +export interface RequestState { + state: RequestStateEnum; + type: 'query' | 'mutation'; + request: Request; + response?: Response; +} + +export interface Response { + // rawData: unknown; + data: unknown; +} diff --git a/packages/store/addon/-private/ts-interfaces/promise-proxies.ts b/packages/store/addon/-private/ts-interfaces/promise-proxies.ts new file mode 100644 index 00000000000..a06361811a1 --- /dev/null +++ b/packages/store/addon/-private/ts-interfaces/promise-proxies.ts @@ -0,0 +1,3 @@ +// shim type until we can properly type +// these proxies +export type PromiseProxy = Promise; diff --git a/packages/store/addon/-private/utils/promise-record.ts b/packages/store/addon/-private/utils/promise-record.ts index 2cfbb8bbc4e..b8f4e844b0f 100644 --- a/packages/store/addon/-private/utils/promise-record.ts +++ b/packages/store/addon/-private/utils/promise-record.ts @@ -1,15 +1,12 @@ import InternalModel from '../system/model/internal-model'; import { promiseObject } from '../system/promise-proxies'; import { Record } from '../ts-interfaces/record'; - +import { PromiseProxy } from '../ts-interfaces/promise-proxies'; +import { DSModel } from '../ts-interfaces/ds-model'; /** @module @ember-data/store */ -// shim type until we can properly type -// these proxies -export type PromiseProxy = Promise; - /** * Get the materialized model from the internalModel/promise * that returns an internal model and return it in a promiseObject. @@ -21,7 +18,7 @@ export type PromiseProxy = Promise; export default function promiseRecord( internalModelPromise: Promise, label: string -): PromiseProxy { +): PromiseProxy { let toReturn = internalModelPromise.then(internalModel => internalModel.getRecord()); return promiseObject(toReturn, label); diff --git a/yarn.lock b/yarn.lock index 4f86b2055fb..a4fc3ec92c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1694,7 +1694,7 @@ dependencies: "@types/ember__object" "*" -"@types/ember__debug@*", "@types/ember__debug@^3.0.3": +"@types/ember__debug@*", "@types/ember__debug@3.0.4", "@types/ember__debug@^3.0.3": version "3.0.4" resolved "https://registry.npmjs.org/@types/ember__debug/-/ember__debug-3.0.4.tgz#cdf87a580688a0e3053820eff6f390fbb7ba0e80" integrity sha512-jTdLdNGvDn3MhktfskhdxOaDHO09QtQqeh+krI7EDePl2+Xom+KnNeveFeCkzxDkYOa+/R7UNSxW4yN/3YTw3w==