From 5a490a6149dc01eb3d75f852e789e3d7f98fe166 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Sat, 9 Dec 2023 19:51:03 -0800 Subject: [PATCH] feat: improved lifetimes-service capabilities (#9163) * feat: improved lifetimes-service capabilities * fix lint --- packages/request-utils/src/index.ts | 48 ++++++--- packages/store/src/-private/cache-handler.ts | 100 +++++++++++++++++- tests/docs/fixtures/expected.js | 4 + .../app/services/store.ts | 2 +- 4 files changed, 134 insertions(+), 20 deletions(-) diff --git a/packages/request-utils/src/index.ts b/packages/request-utils/src/index.ts index 6ebffb379c7..90e8bab1487 100644 --- a/packages/request-utils/src/index.ts +++ b/packages/request-utils/src/index.ts @@ -1,7 +1,7 @@ -import { assert } from '@ember/debug'; +import { assert, deprecate } from '@ember/debug'; -import type Store from '@ember-data/store'; -import { StableDocumentIdentifier } from '@ember-data/types/cache/identifier'; +import type { StableDocumentIdentifier } from '@ember-data/types/cache/identifier'; +import { Cache } from '@ember-data/types/q/cache'; /** * Simple utility function to assist in url building, @@ -602,7 +602,7 @@ export type LifetimesConfig = { apiCacheSoftExpires: number; apiCacheHardExpires * export class Store extends DataStore { * constructor(args) { * super(args); - * this.lifetimes = new LifetimesService(this, { apiCacheSoftExpires: 30_000, apiCacheHardExpires: 60_000 }); + * this.lifetimes = new LifetimesService({ apiCacheSoftExpires: 30_000, apiCacheHardExpires: 60_000 }); * } * } * ``` @@ -611,22 +611,42 @@ export type LifetimesConfig = { apiCacheSoftExpires: number; apiCacheHardExpires * @public * @module @ember-data/request-utils */ -// TODO this doesn't get documented correctly on the website because it shares a class name -// with the interface expected by the Store service export class LifetimesService { - declare store: Store; declare config: LifetimesConfig; - constructor(store: Store, config: LifetimesConfig) { - this.store = store; - this.config = config; + + constructor(config: LifetimesConfig) { + const _config = arguments.length === 1 ? config : (arguments[1] as unknown as LifetimesConfig); + deprecate( + `Passing a Store to the LifetimesService is deprecated, please pass only a config instead.`, + arguments.length === 1, + { + id: 'ember-data:request-utils:lifetimes-service-store-arg', + since: { + enabled: '5.4', + available: '5.4', + }, + for: '@ember-data/request-utils', + until: '6.0', + } + ); + assert(`You must pass a config to the LifetimesService`, _config); + assert( + `You must pass a apiCacheSoftExpires to the LifetimesService`, + typeof _config.apiCacheSoftExpires === 'number' + ); + assert( + `You must pass a apiCacheHardExpires to the LifetimesService`, + typeof _config.apiCacheHardExpires === 'number' + ); + this.config = _config; } - isHardExpired(identifier: StableDocumentIdentifier): boolean { - const cached = this.store.cache.peekRequest(identifier); + isHardExpired(identifier: StableDocumentIdentifier, cache: Cache): boolean { + const cached = cache.peekRequest(identifier); return !cached || !cached.response || isStale(cached.response.headers, this.config.apiCacheHardExpires); } - isSoftExpired(identifier: StableDocumentIdentifier): boolean { - const cached = this.store.cache.peekRequest(identifier); + isSoftExpired(identifier: StableDocumentIdentifier, cache: Cache): boolean { + const cached = cache.peekRequest(identifier); return !cached || !cached.response || isStale(cached.response.headers, this.config.apiCacheSoftExpires); } } diff --git a/packages/store/src/-private/cache-handler.ts b/packages/store/src/-private/cache-handler.ts index 2fe4fb4b510..5b574156545 100644 --- a/packages/store/src/-private/cache-handler.ts +++ b/packages/store/src/-private/cache-handler.ts @@ -1,3 +1,6 @@ +/** + * @module @ember-data/store + */ import { assert } from '@ember/debug'; import type { @@ -21,10 +24,85 @@ import type { RecordInstance } from '@ember-data/types/q/record-instance'; import type { CreateRequestOptions, DeleteRequestOptions, UpdateRequestOptions } from '@ember-data/types/request'; import { Document } from './document'; - +import { Cache } from '@ember-data/types/q/cache'; + +/** + * A service which an application may provide to the store via + * the store's `lifetimes` property to configure the behavior + * of the CacheHandler. + * + * The default behavior for request lifetimes is to never expire + * unless manually refreshed via `cacheOptions.reload` or `cacheOptions.backgroundReload`. + * + * Implementing this service allows you to programatically define + * when a request should be considered expired. + * + * @class LifetimesService + * @public + */ export interface LifetimesService { - isHardExpired(identifier: StableDocumentIdentifier): boolean; - isSoftExpired(identifier: StableDocumentIdentifier): boolean; + /** + * Invoked to determine if the request may be fulfilled from cache + * if possible. + * + * Note, this is only invoked if the request has a cache-key. + * + * If no cache entry is found or the entry is hard expired, + * the request will be fulfilled from the configured request handlers + * and the cache will be updated before returning the response. + * + * @method isHardExpired + * @public + * @param {StableDocumentIdentifier} identifier + * @param {Cache} cache + * @returns {boolean} true if the request is considered hard expired + */ + isHardExpired(identifier: StableDocumentIdentifier, cache: Cache): boolean; + /** + * Invoked if `isHardExpired` is false to determine if the request + * should be update behind the scenes if cache data is already available. + * + * Note, this is only invoked if the request has a cache-key. + * + * If true, the request will be fulfilled from cache while a backgrounded + * request is made to update the cache via the configured request handlers. + * + * @method isSoftExpired + * @public + * @param {StableDocumentIdentifier} identifier + * @param {Cache} cache + * @returns {boolean} true if the request is considered soft expired + */ + isSoftExpired(identifier: StableDocumentIdentifier, cache: Cache): boolean; + + /** + * Invoked when a request will be sent to the configured request handlers. + * This is invoked for both foreground and background requests. + * + * Note, this is only invoked if the request has a cache-key. + * + * @method willRequest [Optional] + * @public + * @param {StableDocumentIdentifier} identifier + * @param {Cache} cache + * @returns {void} + */ + willRequest?(identifier: StableDocumentIdentifier, cache: Cache): void; + + /** + * Invoked when a request has been fulfilled from the configured request handlers. + * This is invoked for both foreground and background requests once the cache has + * been updated. + * + * Note, this is only invoked if the request has a cache-key. + * + * @method didRequest [Optional] + * @public + * @param {StableDocumentIdentifier} identifier + * @param {Cache} cache + * @returns {void} + */ + didRequest?(identifier: StableDocumentIdentifier, cache: Cache): void; } export type StoreRequestInfo = ImmutableRequestInfo; @@ -168,7 +246,7 @@ function calcShouldFetch( (request.op && MUTATION_OPS.has(request.op)) || cacheOptions?.reload || !hasCachedValue || - (store.lifetimes && identifier ? store.lifetimes.isHardExpired(identifier) : false) + (store.lifetimes && identifier ? store.lifetimes.isHardExpired(identifier, store.cache) : false) ); } @@ -182,7 +260,7 @@ function calcShouldBackgroundFetch( return ( !willFetch && (cacheOptions?.backgroundReload || - (store.lifetimes && identifier ? store.lifetimes.isSoftExpired(identifier) : false)) + (store.lifetimes && identifier ? store.lifetimes.isSoftExpired(identifier, store.cache) : false)) ); } @@ -211,6 +289,10 @@ function fetchContentAndHydrate( store.cache.willCommit(record, context); } + if (identifier && store.lifetimes?.willRequest) { + store.lifetimes.willRequest(identifier, store.cache); + } + const promise = next(context.request).then( (document) => { store.requestManager._pending.delete(context.id); @@ -232,6 +314,10 @@ function fetchContentAndHydrate( }); store._enableAsyncFlush = null; + if (identifier && store.lifetimes?.didRequest) { + store.lifetimes.didRequest(identifier, store.cache); + } + if (shouldFetch) { return response!; } else if (shouldBackgroundFetch) { @@ -274,6 +360,10 @@ function fetchContentAndHydrate( }); store._enableAsyncFlush = null; + if (identifier && store.lifetimes?.didRequest) { + store.lifetimes.didRequest(identifier, store.cache); + } + if (!shouldBackgroundFetch) { const newError = cloneError(error); newError.content = response; diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 4a167536b67..7545f6e6814 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -33,6 +33,10 @@ module.exports = { '(public) @ember-data/store IdentifierCache#getOrCreateDocumentIdentifier', '(public) @ember-data/store Store#registerSchema', '(public) @ember-data/store Store#schema', + '(public) @ember-data/store LifetimesService#didRequest [Optional]', + '(public) @ember-data/store LifetimesService#isHardExpired', + '(public) @ember-data/store LifetimesService#isSoftExpired', + '(public) @ember-data/store LifetimesService#willRequest [Optional]', '(private) @ember-data/adapter BuildURLMixin#_buildURL', '(private) @ember-data/adapter BuildURLMixin#urlPrefix', '(private) @ember-data/adapter/json-api JSONAPIAdapter#ajaxOptions', diff --git a/tests/recommended-json-api/app/services/store.ts b/tests/recommended-json-api/app/services/store.ts index 4f29b02ccb0..ad1a75c7233 100644 --- a/tests/recommended-json-api/app/services/store.ts +++ b/tests/recommended-json-api/app/services/store.ts @@ -21,7 +21,7 @@ export default class Store extends DataStore { manager.useCache(CacheHandler); this.registerSchema(buildSchema(this)); - this.lifetimes = new LifetimesService(this, CONFIG); + this.lifetimes = new LifetimesService(CONFIG); } createCache(capabilities: CacheCapabilitiesManager): Cache {