Skip to content

Commit

Permalink
feat: improve lifetime handling of ad-hoc createRecord requests (#9314)
Browse files Browse the repository at this point in the history
* feat: improve lifetime handling of ad-hoc createRecord requests

* add tests and fixes for empty 201/204
  • Loading branch information
runspired authored Apr 4, 2024
1 parent c3d77e3 commit b6a00a7
Show file tree
Hide file tree
Showing 5 changed files with 616 additions and 11 deletions.
7 changes: 6 additions & 1 deletion packages/core-types/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export type CacheOptions = {
* provided by `@ember-data/request-utils` for an example.
*
* It is recommended to only use this for query/queryRecord requests where
* new records created later would affect the results.
* new records created later would affect the results, though using it for
* findRecord requests is also supported if desired where it may be useful
* when a create may affect the result of a sideloaded relationship.
*
* Generally it is better to patch the cache directly for relationship updates
* than to invalidate findRecord requests for one.
*
* @typedoc
*/
Expand Down
20 changes: 18 additions & 2 deletions packages/request-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,14 +654,25 @@ export type LifetimesConfig = { apiCacheSoftExpires: number; apiCacheHardExpires
* Invalidates any request for which `cacheOptions.types` was provided when a createRecord
* request for that type is successful.
*
* For this to work, the `createRecord` request must include the `cacheOptions.types` array
* with the types that should be invalidated, or its request should specify the identifiers
* of the records that are being created via `records`. Providing both is valid.
*
* > [!NOTE]
* > only requests that had specified `cacheOptions.types` and occurred prior to the
* > createRecord request will be invalidated. This means that a given request should always
* > specify the types that would invalidate it to opt into this behavior. Abstracting this
* > behavior via builders is recommended to ensure consistency.
*
* This allows the Store's CacheHandler to determine if a request is expired and
* should be refetched upon next request.
*
* The `Fetch` handler provided by `@ember-data/request/fetch` will automatically
* add the `date` header to responses if it is not present.
*
* Note: Date headers do not have millisecond precision, so expiration times should
* generally be larger than 1000ms.
* > [!NOTE]
* > Date headers do not have millisecond precision, so expiration times should
* > generally be larger than 1000ms.
*
* Usage:
*
Expand Down Expand Up @@ -804,6 +815,11 @@ export class LifetimesService {
const statusNumber = response?.status ?? 0;
if (statusNumber >= 200 && statusNumber < 400) {
const types = new Set(request.records?.map((r) => r.type));
const additionalTypes = request.cacheOptions?.types;
additionalTypes?.forEach((type) => {
types.add(type);
});

types.forEach((type) => {
this.invalidateRequestsForType(type, store);
});
Expand Down
82 changes: 78 additions & 4 deletions packages/store/src/-private/cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,16 @@ function maybeUpdateUiObjects<T>(
shouldBackgroundFetch?: boolean;
identifier: StableDocumentIdentifier | null;
},
document: ResourceDataDocument | ResourceErrorDocument,
document: ResourceDataDocument | ResourceErrorDocument | null,
isFromCache: boolean
): T {
const { identifier } = options;

if (!document) {
assert(`The CacheHandler expected response content but none was found`, !options.shouldHydrate);
return document as T;
}

if (isErrorDocument(document)) {
if (!identifier && !options.shouldHydrate) {
return document as T;
Expand Down Expand Up @@ -294,8 +299,13 @@ function fetchContentAndHydrate<T>(
isMut = true;
// TODO should we handle multiple records in request.records by iteratively calling willCommit for each
const record = context.request.data?.record || context.request.records?.[0];
assert(`Expected to receive a list of records included in the ${context.request.op} request`, record);
store.cache.willCommit(record, context);
assert(
`Expected to receive a list of records included in the ${context.request.op} request`,
record || !shouldHydrate
);
if (record) {
store.cache.willCommit(record, context);
}
}

if (store.lifetimes?.willRequest) {
Expand All @@ -310,7 +320,15 @@ function fetchContentAndHydrate<T>(
store._join(() => {
if (isMutation(context.request)) {
const record = context.request.data?.record || context.request.records?.[0];
response = store.cache.didCommit(record, document) as ResourceDataDocument;
if (record) {
response = store.cache.didCommit(record, document) as ResourceDataDocument;

// a mutation combined with a 204 has no cache impact when no known records were involved
// a createRecord with a 201 with an empty response and no known records should similarly
// have no cache impact
} else if (isCacheAffecting(document)) {
response = store.cache.put(document) as ResourceDataDocument;
}
} else {
response = store.cache.put(document) as ResourceDataDocument;
}
Expand Down Expand Up @@ -411,6 +429,47 @@ function cloneError(error: Error & { error: string | object }) {
return cloned;
}

/**
* A CacheHandler that adds support for using an EmberData Cache with a RequestManager.
*
* This handler will only run when a request has supplied a `store` instance. Requests
* issued by the store via `store.request()` will automatically have the `store` instance
* attached to the request.
*
* ```ts
* requestManager.request({
* store: store,
* url: '/api/posts',
* method: 'GET'
* });
* ```
*
* When this handler elects to handle a request, it will return the raw `StructuredDocument`
* unless the request has `[EnableHydration]` set to `true`. In this case, the handler will
* return a `Document` instance that will automatically update the UI when the cache is updated
* in the future and will hydrate any identifiers in the StructuredDocument into Record instances.
*
* When issuing a request via the store, [EnableHydration] is automatically set to `true`. This
* means that if desired you can issue requests that utilize the cache without needing to also
* utilize Record instances if desired.
*
* Said differently, you could elect to issue all requests via a RequestManager, without ever using
* the store directly, by setting [EnableHydration] to `true` and providing a store instance. Not
* necessarily the most useful thing, but the decoupled nature of the RequestManager and incremental-feature
* approach of EmberData allows for this flexibility.
*
* ```ts
* import { EnableHydration } from '@warp-drive/core-types/request';
*
* requestManager.request({
* store: store,
* url: '/api/posts',
* method: 'GET',
* [EnableHydration]: true
* });
*
* @typedoc
*/
export const CacheHandler: CacheHandlerType = {
request<T>(context: StoreRequestContext, next: NextFn<T>): Promise<T | StructuredDataDocument<T>> | Future<T> | T {
// if we have no cache or no cache-key skip cache handling
Expand Down Expand Up @@ -476,3 +535,18 @@ function copyDocumentProperties(target: { links?: unknown; meta?: unknown; error
target.errors = source.errors;
}
}

function isCacheAffecting<T>(document: StructuredDataDocument<T>): boolean {
if (!isMutation(document.request)) {
return true;
}
// a mutation combined with a 204 has no cache impact when no known records were involved
// a createRecord with a 201 with an empty response and no known records should similarly
// have no cache impact

if (document.request.op === 'createRecord' && document.response?.status === 201) {
return document.content ? Object.keys(document.content).length > 0 : false;
}

return document.response?.status !== 204;
}
Loading

0 comments on commit b6a00a7

Please sign in to comment.