import { action, makeObservable, observable, runInAction, toJS } from 'mobx'
import pick from 'lodash/pick'
import {
fetchWithRetry,
deriveIdQueryStrings,
parseErrors,
parseErrorPointer,
requestUrl,
newId
} from './utils'
import cloneDeep from 'lodash/cloneDeep'
/**
* Annotations for mobx observability. We can't use `makeAutoObservable` because we have subclasses.
*/
const mobxAnnotations = {
data: observable,
lastResponseHeaders: observable,
loadingStates: observable,
loadedStates: observable,
add: action,
pickAttributes: action,
pickRelationships: action,
bulkSave: action,
_bulkSave: action,
bulkCreate: action,
bulkUpdate: action,
remove: action,
getOne: action,
fetchOne: action,
findOne: action,
getMany: action,
fetchMany: action,
findMany: action,
fetchUrl: action,
getAll: action,
setLoadingState: action,
deleteLoadingState: action,
fetchAll: action,
findAll: action,
reset: action,
init: action,
initializeNetworkConfiguration: action,
initializeModelIndex: action,
initializeErrorMessages: action,
fetch: action,
getRecord: action,
getRecords: action,
getRecordsById: action,
clearCache: action,
getCachedRecord: action,
getCachedRecords: action,
getCachedIds: action,
getCachedId: action,
getKlass: action,
createOrUpdateModelFromData: action,
updateRecordFromData: action,
createOrUpdateModelsFromData: action,
createModelFromData: action,
updateRecordsFromResponse: action
}
/**
* Defines the Data Store class.
*/
class Store {
/**
* Stores data by type.
* {
* todos: {
* records: observable.map(), // records by id
* cache: observable.map(), // cached ids by url
* meta: observable.map() // meta information by url
* }
* }
*
* @type {object}
* @default {}
*/
data = {}
/**
* The most recent response headers according to settings specified as `headersOfInterest`
*
* @type {object}
* @default {}
*/
lastResponseHeaders = {}
/**
* Map of data that is in flight. This can be observed to know if a given type (or tag)
* is still processing.
* - Key is a tag that is either the model type or a custom value
* - Falue is a Set of JSON-encoded objects with unique urls and queryParams
* Set[JSON.stringify({ url, type, queryParams, queryTag })]
*
* @type {Map}
*/
loadingStates = new Map()
/**
* Map of data that has been loaded into the store. This can be observed to know if a given
* type (or tag) has finished loading.
* - Key is a tag that is either the model type or a custom value
* - Falue is a Set of JSON-encoded objects with unique urls and queryParams
* Set[JSON.stringify({ url, type, queryParams, queryTag })]
*
* @type {Map}
*/
loadedStates = new Map()
/**
* True if models in the store should stop taking snapshots. This is
* useful when updating records without causing records to become
* 'dirty', for example when initializing records using `add`
*
* @type {boolean}
*/
pauseSnapshots = false
/**
* Initializer for Store class
*
* @param {object} options options to use for initialization
*/
constructor (options) {
makeObservable(this, mobxAnnotations)
this.init(options)
}
/**
* Adds an instance or an array of instances to the store.
* Adds the model to the type records index
* Adds relationships explicitly. This is less efficient than adding via data if
* there are also inverse relationships.
*
* ```
* const todo = store.add('todos', { name: "A good thing to measure" })
* todo.name
* => "A good thing to measure"
*
* const todoArray = [{ name: "Another good thing to measure" }]
* const [todo] = store.add('todos', [{ name: "Another good thing to measure" }])
* todo.name
* => "Another good thing to measure"
* ```
*
* @param {string} type the model type
* @param {object|Array} props the properties to use
* @param {object} options currently supports `skipInitialization`
* @returns {object|Array} the new record or records
*/
add (type, props = {}, options) {
if (props.constructor.name === 'Array') {
return props.map((model) => this.add(type, model))
} else {
const id = String(props.id || newId())
const attributes = cloneDeep(this.pickAttributes(props, type))
const record = this.createModelFromData({ type, id, attributes }, options)
// set separately to get inverses
this.pauseSnapshots = true
Object.entries(this.pickRelationships(props, type)).forEach(([key, value]) => {
record[key] = value
})
this.pauseSnapshots = false
this.data[type].records.set(id, record)
return record
}
}
/**
* Given a set of properties and type, returns an object with only the properties
* that are defined as attributes in the model for that type.
* ```
* properties = { title: 'Do laundry', unrelatedProperty: 'Do nothing' }
* pickAttributes(properties, 'todos')
* => { title: 'Do laundry' }
* ```
*
* @param {object} properties a full list of properties that may or may not conform
* @param {string} type the model type
* @returns {object} the scrubbed attributes
*/
pickAttributes (properties, type) {
const attributeNames = Object.keys(this.getKlass(type).attributeDefinitions)
return pick(properties, attributeNames)
}
/**
* Given a set of properties and type, returns an object with only the properties
* that are defined as relationships in the model for that type.
* ```
* properties = { notes: [note1, note2], category: cat1, title: 'Fold Laundry' }
* pickRelationships(properties, 'todos')
* => {
* notes: {
* data: [{ id: '1', type: 'notes' }, { id: '2', type: 'notes' }]
* },
* category: {
* data: { id: '1', type: 'categories' }
* }
* }
* ```
*
* @param {object} properties a full list of properties that may or may not conform
* @param {string} type the model type
* @returns {object} the scrubbed relationships
*/
pickRelationships (properties, type) {
const definitions = this.getKlass(type).relationshipDefinitions
return definitions ? pick(properties, Object.keys(definitions)) : {}
}
/**
* Saves a collection of records via a bulk-supported JSONApi endpoint.
* All records need to be of the same type.
*
* @param {string} type the model type
* @param {Array} records records that will be bulk saved
* @param {object} options {queryParams, extensions}
* @returns {Promise} the saved records
*/
bulkSave (type, records, options = {}) {
console.warn('bulkSave is deprecated. Please use either bulkCreate or bulkUpdate to be more precise about your request.')
return this._bulkSave(type, records, options, 'POST')
}
/**
* Saves a collection of records via a bulk-supported JSONApi endpoint.
* All records need to be of the same type.
* - gets url for record type
* - converts records to an appropriate jsonapi attribute/relationship format
* - builds a data payload
* - builds the json api extension string
* - sends request
* - update records based on response
*
* @private
* @param {string} type the model type
* @param {Array} records records to be bulk saved
* @param {object} options {queryParams, extensions}
* @param {string} method http method
* @returns {Promise} the saved records
*/
_bulkSave (type, records, options = {}, method) {
const { queryParams, extensions } = options
const url = this.fetchUrl(type, queryParams, null)
const recordAttributes = records.map((record) => record.jsonapi(options))
const body = JSON.stringify({ data: recordAttributes })
const extensionStr = extensions?.length
? `ext="bulk,${extensions.join()}"`
: 'ext="bulk"'
const response = this.fetch(url, {
headers: {
...this.defaultFetchOptions.headers,
'Content-Type': `application/vnd.api+json; ${extensionStr}`
},
method,
body
})
return this.updateRecordsFromResponse(response, records)
}
/**
* Save a collection of new records via a bulk-supported JSONApi endpoint.
* All records need to be of the same type and not have an existing id.
*
* @param {string} type the model type
* @param {Array} records to be bulk created
* @param {object} options {queryParams, extensions}
* @returns {Promise} the created records
*/
bulkCreate (type, records, options = {}) {
if (records.some((record) => !record.isNew)) {
throw new Error('Invariant violated: all records must be new records to perform a create')
}
return this._bulkSave(type, records, options, 'POST')
}
/**
* Updates a collection of records via a bulk-supported JSONApi endpoint.
* All records need to be of the same type and have an existing id.
*
* @param {string} type the model type
* @param {Array} records array of records to be bulk updated
* @param {object} options {queryParams, extensions}
* @returns {Promise} the saved records
*/
bulkUpdate (type, records, options = {}) {
if (records.some((record) => record.isNew)) {
throw new Error('Invariant violated: all records must have a persisted id to perform an update')
}
return this._bulkSave(type, records, options, 'PATCH')
}
/**
* Removes a record from the store by deleting it from the
* type's record map
*
* @param {string} type the model type
* @param {string} id of record to remove
*/
remove (type, id) {
this.data[type].records.delete(String(id))
}
/**
* Gets a record from the store. Will never fetch from the server.
* If given queryParams, it will check the cache for the record.
*
* @param {string} type the type to find
* @param {string} id the id of the record to get
* @param {object} options { queryParams }
* @returns {object} record
*/
getOne (type, id, options = {}) {
if (!id) {
console.error(`No id given while calling 'getOne' on ${type}`)
return undefined
}
const { queryParams } = options
if (queryParams) {
return this.getCachedRecord(type, id, queryParams)
} else {
return this.getRecord(type, id)
}
}
/**
* Fetches record by `id` from the server and returns a Promise.
*
* @async
* @param {string} type the record type to fetch
* @param {string} id the id of the record to fetch
* @param {object} options { queryParams }
* @returns {Promise} record result wrapped in a Promise
*/
async fetchOne (type, id, options = {}) {
if (!id) {
console.error(`No id given while calling 'fetchOne' on ${type}`)
return undefined
}
const { queryParams } = options
const url = this.fetchUrl(type, queryParams, id)
const state = this.setLoadingState({ ...options, type, id, url })
const response = await this.fetch(url, { method: 'GET' })
if (response.status === 200) {
const { data, included } = await response.json()
const record = this.createOrUpdateModelFromData(data)
if (included) {
this.createOrUpdateModelsFromData(included)
}
this.data[type].cache.set(url, [record.id])
this.deleteLoadingState(state)
return record
} else {
this.deleteLoadingState(state)
const errors = await parseErrors(response, this.errorMessages)
throw new Error(JSON.stringify(errors))
}
}
/**
* Finds a record by `id`, always returning a Promise.
* If available in the store, it returns that record. Otherwise, it fetches the record from the server.
*
* store.findOne('todos', 5)
* // fetch triggered
* => Promise(todo)
* store.findOne('todos', 5)
* // no fetch triggered
* => Promise(todo)
*
* @param {string} type the type to find
* @param {string} id the id of the record to find
* @param {object} options { queryParams }
* @returns {Promise} a promise that will resolve to the record
*/
findOne (type, id, options = {}) {
if (!id) {
console.error(`No id given while calling 'findOne' on ${type}`)
return undefined
}
const record = this.getOne(type, id, options)
return record?.id ? record : this.fetchOne(type, id, options)
}
/**
* Get all records with the given `type` and `ids` from the store. This will never fetch from the server.
*
* @param {string} type the type to get
* @param {string} ids the ids of the records to get
* @param {object} options { queryParams }
* @returns {Array} array of records
*/
getMany (type, ids, options = {}) {
const idsToQuery = ids.slice().map(String)
const records = this.getAll(type, options)
return records.filter((record) => idsToQuery.includes(record.id))
}
/**
* Fetch all records with the given `type` and `ids` from the server.
*
* @param {string} type the type to get
* @param {string} ids the ids of the records to get
* @param {object} options { queryParams }
* @returns {Promise} Promise.resolve(records) or Promise.reject([Error: [{ detail, status }])
*/
fetchMany (type, ids, options = {}) {
const idsToQuery = ids.slice().map(String)
const { queryParams = {}, queryTag } = options
queryParams.filter = queryParams.filter || {}
const baseUrl = this.fetchUrl(type, queryParams)
const idQueries = deriveIdQueryStrings(idsToQuery, baseUrl)
const queries = idQueries.map((queryIds) => {
const params = cloneDeep(queryParams)
params.filter.ids = queryIds
return this.fetchAll(type, { queryParams: params, queryTag })
})
return Promise.all(queries)
.then(records => [].concat(...records))
.catch(err => Promise.reject(err))
}
/**
* Finds multiple records of the given `type` with the given `ids` and returns them wrapped in a Promise.
* If all records are in the store, it returns those.
* If some records are in the store, it returns those plus fetches all other records.
* Otherwise, it fetches all records from the server.
*
* store.findMany('todos', [1, 2, 3])
* // fetch triggered
* => [todo1, todo2, todo3]
*
* store.findMany('todos', [3, 2, 1])
* // no fetch triggered
* => [todo1, todo2, todo3]
*
* @param {string} type the type to find
* @param {string} ids the ids of the records to find
* @param {object} options { queryParams }
* @returns {Promise} a promise that will resolve an array of records
*/
async findMany (type, ids, options = {}) {
ids = [...new Set(ids)].map(String)
const existingRecords = this.getMany(type, ids, options)
if (ids.length === existingRecords.length) {
return existingRecords
}
const existingIds = existingRecords.map(({ id }) => id)
const idsToQuery = ids.filter((id) => !existingIds.includes(id))
const { queryParams = {}, queryTag } = options
queryParams.filter = queryParams.filter || {}
const baseUrl = this.fetchUrl(type, queryParams)
const idQueries = deriveIdQueryStrings(idsToQuery, baseUrl)
await Promise.all(
idQueries.map((queryIds) => {
queryParams.filter.ids = queryIds
return this.fetchAll(type, { queryParams, queryTag })
})
)
return this.getMany(type, ids)
}
/**
* Builds fetch url based on type, queryParams, id, and options
*
* @param {string} type the type to find
* @param {object} queryParams params to be used in the fetch
* @param {string} id a model id
* @param {object} options options for fetching
* @returns {string} a formatted url
*/
fetchUrl (type, queryParams, id, options) {
const { baseUrl } = this
const { endpoint } = this.getKlass(type)
return requestUrl(baseUrl, endpoint, queryParams, id, options)
}
/**
* Gets all records with the given `type` from the store. This will never fetch from the server.
*
* @param {string} type the type to find
* @param {object} options options for fetching queryParams
* @returns {Array} array of records
*/
getAll (type, options = {}) {
const { queryParams } = options
if (queryParams) {
return this.getCachedRecords(type, queryParams)
} else {
return this.getRecords(type).filter((record) => record.initialized)
}
}
/**
* Sets a loading state when a fetch / deserialization is in flight. Loading states
* are Sets inside of the `loadingStates` Map, so multiple loading states can be in flight
* at the same time. An optional query tag can be passed to identify the particular query.
*
* const todos = store.fetchAll('todos', { queryTag: 'myTodos' })
* store.loadingStates.get('myTodos')
* => Set([JSON.stringify({ url, type, queryParams, queryTag })])
*
* @param {object} options options that can be used to build the loading state info
* @param {string} options.url the url queried
* @param {string} options.type the model type
* @param {string} options.queryParams the query params used
* @param {string} options.queryTag an optional tag to use in place of the type
* @returns {object} the loading state that was added
*/
setLoadingState ({ url, type, queryParams, queryTag }) {
queryTag = queryTag || type
const loadingStateInfo = { url, type, queryParams, queryTag }
if (!this.loadingStates.get(queryTag)) {
this.loadingStates.set(queryTag, new Set())
}
this.loadingStates.get(queryTag).add(JSON.stringify(loadingStateInfo))
return loadingStateInfo
}
/**
* Removes a loading state. If that leaves an empty array for the map key in `loadingStates`,
* will also delete the set. Also adds to loadedStates.
*
* @param {object} state the state to remove
*/
deleteLoadingState (state) {
const { loadingStates, loadedStates } = this
const { queryTag } = state
const encodedState = JSON.stringify(state)
if (!loadedStates.get(queryTag)) {
loadedStates.set(queryTag, new Set())
}
loadedStates.get(queryTag).add(encodedState)
if (loadingStates.get(queryTag)) {
loadingStates.get(queryTag).delete(encodedState)
if (loadingStates.get(queryTag).size === 0) {
loadingStates.delete(queryTag)
}
} else {
console.warn(`no loadingState found for ${encodedState}`)
}
}
/**
* Finds all records with the given `type`. Always fetches from the server.
*
* @async
* @param {string} type the type to find
* @param {object} options query params and other options
* @returns {Promise} Promise.resolve(records) or Promise.reject([Error: [{ detail, status }])
*/
async fetchAll (type, options = {}) {
const { queryParams } = options
const url = this.fetchUrl(type, queryParams)
const state = this.setLoadingState({ ...options, type, url })
const response = await this.fetch(url, { method: 'GET' })
if (response.status === 200) {
const { included, data, meta } = await response.json()
let records
runInAction(() => {
if (included) {
this.createOrUpdateModelsFromData(included)
}
records = this.createOrUpdateModelsFromData(data)
const recordIds = records.map(({ id }) => id)
this.data[type].cache.set(url, recordIds)
this.deleteLoadingState(state)
})
if (meta) {
records.meta = meta
this.data[type].meta.set(url, meta)
}
return records
} else {
runInAction(() => {
this.deleteLoadingState(state)
})
const errors = await parseErrors(response, this.errorMessages)
throw new Error(JSON.stringify(errors))
}
}
/**
* Finds all records of the given `type`.
* If any records from the given type from url are in the store, it returns those.
* Otherwise, it fetches all records from the server.
*
* store.findAll('todos')
* // fetch triggered
* => [todo1, todo2, todo3]
*
* store.findAll('todos')
* // no fetch triggered
* => [todo1, todo2, todo3]
*
* Query params can be passed as part of the options hash.
* The response will be cached, so the next time `findAll`
* is called with identical params and values, the store will
* first look for the local result.
*
* store.findAll('todos', {
* queryParams: {
* filter: {
* start_time: '2020-06-01T00:00:00.000Z',
* end_time: '2020-06-02T00:00:00.000Z'
* }
* }
* })
*
* @param {string} type the type to find
* @param {object} options { queryParams }
* @returns {Promise} Promise.resolve(records) or Promise.reject([Error: [{ detail, status }])
*/
findAll (type, options) {
const records = this.getAll(type, options)
if (records?.length > 0) {
return Promise.resolve(records)
} else {
return this.fetchAll(type, options)
}
}
/**
* Clears the store of a given type, or clears all if no type given
*
* store.reset('todos')
* // removes all todos from store
* store.reset()
* // clears store
*
* @param {string} type the model type
*/
reset (type) {
const types = type ? [type] : this.models.map(({ type }) => type)
types.forEach((type) => {
this.data[type] = {
records: observable.map(),
cache: observable.map(),
meta: observable.map()
}
})
}
/**
* Entry point for configuring the store
*
* @param {object} options passed to constructor
*/
init (options = {}) {
this.initializeNetworkConfiguration(options)
this.initializeModelIndex(options.models)
this.reset()
this.initializeErrorMessages(options)
}
/**
* Configures the store's network options
*
* @param {string} options the parameters that will be used to set up network requests
* @param {string} options.baseUrl the API's root url
* @param {object} options.defaultFetchOptions options that will be used when fetching
* @param {Array} options.headersOfInterest an array of headers to watch
* @param {object} options.retryOptions options for re-fetch attempts and interval
*/
initializeNetworkConfiguration ({ baseUrl = '', defaultFetchOptions = {}, headersOfInterest = [], retryOptions = { attempts: 1, delay: 0 } }) {
this.baseUrl = baseUrl
this.defaultFetchOptions = defaultFetchOptions
this.headersOfInterest = headersOfInterest
this.retryOptions = retryOptions
}
/**
* Creates the key/value index of model types
*
* @param {object} models a fallback list of models
*/
initializeModelIndex (models) {
this.models = this.constructor.models || models
}
/**
* Configure the error messages returned from the store when API requests fail
*
* @param {object} options for initializing the store
* options for initializing error messages for different HTTP status codes
*/
initializeErrorMessages (options = {}) {
const errorMessages = { ...options.errorMessages }
this.errorMessages = {
default: 'Something went wrong.',
...errorMessages
}
}
/**
* Wrapper around fetch applies user defined fetch options
*
* @param {string} url the url to fetch
* @param {object} options override options to use for fetching
* @returns {Promise} the data from the server
*/
async fetch (url, options = {}) {
const { defaultFetchOptions, headersOfInterest, retryOptions } = this
const fetchOptions = { ...defaultFetchOptions, ...options }
const { attempts, delay } = retryOptions
const response = await fetchWithRetry(url, fetchOptions, attempts, delay)
if (headersOfInterest) {
runInAction(() => {
headersOfInterest.forEach(header => {
const value = response.headers.get(header)
// Only set if it has changed, to minimize observable changes
if (this.lastResponseHeaders[header] !== value) this.lastResponseHeaders[header] = value
})
})
}
return response
}
/**
* Gets individual record from store
*
* @param {string} type the model type
* @param {number} id the model id
* @returns {object} record
*/
getRecord (type, id) {
if (!this.data[type]) {
throw new Error(`Could not find a collection for type '${type}'`)
}
const record = this.data[type].records.get(String(id))
return (!record || record === 'undefined') ? undefined : record
}
/**
* Gets records for type of collection
*
* @param {string} type the model type
* @returns {Array} array of objects
*/
getRecords (type) {
return Array.from(this.data[type].records.values())
}
/**
* Get multiple records by id
*
* @param {string} type the model type
* @param {Array} ids the ids to find
* @returns {Array} array or records
*/
getRecordsById (type, ids = []) {
// NOTE: Is there a better way to do this?
return ids
.map((id) => this.getRecord(type, id))
.filter((record) => record)
.filter((record) => typeof record !== 'undefined')
}
/**
* Clears the cache for provided record type
*
* @param {string} type the model type
* @returns {Set} the cleared set
*/
clearCache (type) {
return this.data[type].cache.clear()
}
/**
* Gets single from store based on cached query
*
* @param {string} type the model type
* @param {string} id the model id
* @param {object} queryParams the params to be searched
* @returns {object} record
*/
getCachedRecord (type, id, queryParams) {
const cachedRecords = this.getCachedRecords(type, queryParams, id)
return cachedRecords && cachedRecords[0]
}
/**
* Gets records from store based on cached query and any previously requested ids
*
* @param {string} type type of records to get
* @param {object} queryParams query params that were used for the query
* @param {string} id optional param if only getting 1 cached record by id
* @returns {Array} array of records
*/
getCachedRecords (type, queryParams, id) {
const url = this.fetchUrl(type, queryParams, id)
const ids = this.getCachedIds(type, url)
const meta = this.data[type].meta.get(url)
const cachedRecords = this.getRecordsById(type, ids)
if (meta) cachedRecords.meta = meta
return cachedRecords
}
/**
* Gets records from store based on cached query
*
* @param {string} type the model type
* @param {string} url the url that was requested
* @returns {Array} array of ids
*/
getCachedIds (type, url) {
const ids = this.data[type].cache.get(url)
if (!ids) return []
const idsSet = new Set(toJS(ids))
return Array.from(idsSet)
}
/**
* Gets a record from store based on cached query
*
* @param {string} type the model type
* @param {string} id the id to get
* @returns {object} the cached object
*/
getCachedId (type, id) {
return this.data[type].cache.get(String(id))
}
/**
* Helper to look up model class for type.
*
* @param {string} type the model type
* @returns {Function} model constructor
*/
getKlass (type) {
return this.models.find((model) => model.type === type)
}
/**
* Creates or updates a model
*
* @param {object} data the object will be used to update or create a model
* @returns {object} the record
*/
createOrUpdateModelFromData (data) {
const { id, type } = data
let record = this.getRecord(type, id)
if (record) {
this.updateRecordFromData(record, data)
} else {
record = this.createModelFromData(data)
}
this.data[type].records.set(String(record.id), record)
return record
}
/**
* Updates a record from a jsonapi hash
*
* @param {object} record a Model record
* @param {object} data jsonapi-formatted data
*/
updateRecordFromData (record, data) {
const tmpId = record.id
const { id, type, attributes = {}, relationships = {} } = data
runInAction(() => {
record.id = String(id)
// records that are created as inverses are not initialized
if (!record.initialized) {
record.initialize(data)
}
Object.entries(attributes).forEach(([key, value]) => {
record[key] = value
})
Object.keys(relationships).forEach((relationshipName) => {
if (relationships[relationshipName].included === false) {
delete relationships[relationshipName]
}
})
record.relationships = { ...record.relationships, ...relationships }
})
record.isInFlight = false
record.takeSnapshot({ persisted: true })
runInAction(() => {
this.data[type].records.set(String(tmpId), record)
this.data[type].records.set(String(id), record)
})
}
/**
* Create multiple models from an array of data. It will only build objects
* with defined models, and ignore everything else in the data.
*
* @param {Array} data the array of jsonapi data
* @returns {Array} an array of the models serialized
*/
createOrUpdateModelsFromData (data) {
return data.map((dataObject) => {
if (this.data[dataObject.type]) {
return this.createOrUpdateModelFromData(dataObject)
} else {
console.warn(`no type defined for ${dataObject.type}`)
return null
}
})
}
/**
* Helper to create a new model
*
* @param {object} data id, type, attributes and relationships
* @param {object} options currently supports `skipInitialization`
* @returns {object} model instance
*/
createModelFromData (data, options) {
const { id, type, attributes = {}, relationships = {} } = data
const store = this
const ModelKlass = this.getKlass(type)
if (!ModelKlass) {
throw new Error(`Could not find a model for '${type}'`)
}
return new ModelKlass({ id, relationships, ...attributes }, store, options)
}
/**
* Defines a resolution for an API call that will update a record or
* set of records with the data returned from the API
*
* @param {Promise} promise a response from the API
* @param {object|Array} records to be updated
* @returns {Promise} a resolved promise after operations have been performed
*/
updateRecordsFromResponse (promise, records) {
// records may be a single record, if so wrap it in an array to make
// iteration simpler
const recordsArray = Array.isArray(records) ? records : [records]
recordsArray.forEach((record) => {
record.isInFlight = true
})
return promise.then(
async (response) => {
const { status } = response
recordsArray.forEach((record) => {
record.isInFlight = false
})
if (status === 200 || status === 201) {
const json = await response.json()
const data = Array.isArray(json.data) ? json.data : [json.data]
const { included } = json
if (data.length !== recordsArray.length) {
throw new Error(
'Invariant violated: API response data and records to update do not match'
)
}
recordsArray.forEach((record, i) => this.updateRecordFromData(record, data[i]))
if (included) {
this.createOrUpdateModelsFromData(included)
}
// on success, return the original record(s).
// again - this may be a single record so preserve the structure
return records
} else {
const errors = await parseErrors(response, this.errorMessages)
runInAction(() => {
errors.forEach((error) => {
const { index, key } = parseErrorPointer(error)
if (key != null) {
// add the error to the record
const errors = recordsArray[index].errors[key] || []
errors.push(error)
recordsArray[index].errors[key] = errors
}
})
})
throw new Error(JSON.stringify(errors))
}
},
function (error) {
// TODO: Handle error states correctly, including handling errors for multiple targets
recordsArray.forEach((record) => {
record.isInFlight = false
})
recordsArray[0].errors = error
throw error
}
)
}
}
export default Store