Skip to content

Commit

Permalink
feat: stable document identity (#8531)
Browse files Browse the repository at this point in the history
  • Loading branch information
runspired authored Apr 2, 2023
1 parent d0fc75d commit e316aec
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 59 deletions.
10 changes: 3 additions & 7 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ module.exports = {
parser: '@babel/eslint-parser',
root: true,
parserOptions: {
ecmaVersion: 2018,
ecmaVersion: 2022,
sourceType: 'module',
babelOptions: {
// eslint-disable-next-line node/no-unpublished-require
Expand Down Expand Up @@ -84,15 +84,11 @@ module.exports = {
'qunit/no-identical-names': 'off',
'qunit/require-expect': 'off',
},
globals: {
Map: false,
WeakMap: true,
Set: true,
Promise: false,
},
globals: {},
env: {
browser: true,
node: false,
es6: true,
},
overrides: [
{
Expand Down
47 changes: 39 additions & 8 deletions ember-data-types/q/identifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@module @ember-data/store
*/

import { ImmutableRequestInfo } from '@ember-data/request/-private/types';
import {
DEBUG_CLIENT_ORIGINATED,
DEBUG_IDENTIFIER_BUCKET,
Expand All @@ -10,7 +11,7 @@ import {
import type { ExistingResourceObject, ResourceIdentifierObject } from './ember-data-json-api';

export type ResourceData = ResourceIdentifierObject | ExistingResourceObject;
export type IdentifierBucket = 'record';
export type IdentifierBucket = 'record' | 'document';

export interface Identifier {
lid: string;
Expand Down Expand Up @@ -119,13 +120,15 @@ export type StableRecordIdentifier = StableExistingRecordIdentifier | StableNewR
This configuration MUST occur prior to the store instance being created.
Takes a method which can expect to receive various data as its first argument
and the name of a bucket as its second argument. Currently the second
argument will always be `record` data should conform to a `json-api`
`Resource` interface, but will be the normalized json data for a single
resource that has been given to the store.
and the name of a bucket as its second argument.
The method must return a unique (to at-least the given bucket) string identifier
for the given data as a string to be used as the `lid` of an `Identifier` token.
Currently there are two buckets, 'record' and 'document'.
### Resource (`Record`) Identity
If the bucket is `record` the method must return a unique (to at-least
the given bucket) string identifier for the given data as a string to be
used as the `lid` of an `Identifier` token.
This method will only be called by either `getOrCreateRecordIdentifier` or
`createIdentifierForNewRecord` when an identifier for the supplied data
Expand Down Expand Up @@ -156,13 +159,41 @@ export type StableRecordIdentifier = StableExistingRecordIdentifier | StableNewR
};
```
### Document Identity
If the bucket is `document` the method will receive the associated
immutable `request` passed to `store.request` as its first argument
and should return a unique string for the given request if the document
should be cached, and `null` if it should not be cached.
Note, the request result will still be passed to the cache via `Cache.put`,
but caches should take this as a signal that the document should not itself
be cached, while its contents may still be used to update other cache state.
The presence of `cacheOptions.key` on the request will take precedence
for the document cache key, and this method will not be called if it is
present.
The default method implementation for this bucket is to return `null`
for all requests whose method is not `GET`, and to return the `url` for
those where it is.
This means that queries via `POST` MUST provide `cacheOptions.key` or
implement this hook.
⚠️ Caution: Requests that do not have a `method` assigned are assumed to be `GET`
@method setIdentifierGenerationMethod
@for @ember-data/store
@param method
@public
@static
*/
export type GenerationMethod = (data: ResourceData | { type: string }, bucket: IdentifierBucket) => string;
export interface GenerationMethod {
(data: ImmutableRequestInfo, bucket: 'document'): string | null;
(data: ResourceData | { type: string }, bucket: 'record'): string;
(data: unknown, bucket: IdentifierBucket): string | null;
}

/**
Configure a callback for when the identifier cache encounters new resource
Expand Down
4 changes: 4 additions & 0 deletions packages/model/src/-private/legacy-relationships-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ export class LegacySupport {
op: 'findHasMany',
records: identifiers || [],
data: request,
cacheOptions: { [Symbol.for('ember-data:skip-cache')]: true },
}) as unknown as Promise<void>;
}

Expand All @@ -481,6 +482,7 @@ export class LegacySupport {
op: 'findHasMany',
records: identifiers,
data: request,
cacheOptions: { [Symbol.for('ember-data:skip-cache')]: true },
}) as unknown as Promise<void>;
}

Expand Down Expand Up @@ -538,6 +540,7 @@ export class LegacySupport {
op: 'findBelongsTo',
records: identifier ? [identifier] : [],
data: request,
cacheOptions: { [Symbol.for('ember-data:skip-cache')]: true },
});
this._pending = future
.then((doc) => doc.content)
Expand Down Expand Up @@ -574,6 +577,7 @@ export class LegacySupport {
op: 'findBelongsTo',
records: [identifier],
data: request,
cacheOptions: { [Symbol.for('ember-data:skip-cache')]: true },
})
.then((doc) => doc.content)
.finally(() => {
Expand Down
1 change: 1 addition & 0 deletions packages/model/src/-private/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,7 @@ class Model extends EmberObject {
options,
record: identifier,
},
cacheOptions: { [Symbol.for('ember-data:skip-cache')]: true },
})
.then(() => this)
.finally(() => {
Expand Down
9 changes: 8 additions & 1 deletion packages/request/src/-private/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,15 @@ export interface RequestInfo extends Request {
options?: Record<string, unknown>;
}

const SkipCache = Symbol.for('ember-data:skip-cache');

export interface ImmutableRequestInfo {
readonly cacheOptions?: { key?: string; reload?: boolean; backgroundReload?: boolean };
readonly cacheOptions?: {
key?: string;
reload?: boolean;
backgroundReload?: boolean;
[SkipCache]?: true;
};
readonly store?: Store;

readonly op?: string;
Expand Down
35 changes: 19 additions & 16 deletions packages/store/src/-private/cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import type {
} from '@ember-data/request/-private/types';
import type Store from '@ember-data/store';
import { CollectionResourceDataDocument, ResourceDataDocument } from '@ember-data/types/cache/document';
import { StableDocumentIdentifier } from '@ember-data/types/cache/identifier';

export type HTTPMethod = 'GET' | 'OPTIONS' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export interface LifetimesService {
isHardExpired(key: string, url: string, method: HTTPMethod): boolean;
isSoftExpired(key: string, url: string, method: HTTPMethod): boolean;
isHardExpired(identifier: StableDocumentIdentifier): boolean;
isSoftExpired(identifier: StableDocumentIdentifier): boolean;
}

export type StoreRequestInfo = ImmutableRequestInfo;
Expand Down Expand Up @@ -55,27 +56,27 @@ function calcShouldFetch(
store: Store,
request: StoreRequestInfo,
hasCachedValue: boolean,
lid: string | null | undefined
identifier: StableDocumentIdentifier | null
): boolean {
const { cacheOptions, url, method } = request;
const { cacheOptions } = request;
return (
cacheOptions?.reload ||
!hasCachedValue ||
(store.lifetimes && lid && url && method ? store.lifetimes.isHardExpired(lid, url, method as HTTPMethod) : false)
(store.lifetimes && identifier ? store.lifetimes.isHardExpired(identifier) : false)
);
}

function calcShouldBackgroundFetch(
store: Store,
request: StoreRequestInfo,
willFetch: boolean,
lid: string | null | undefined
identifier: StableDocumentIdentifier | null
): boolean {
const { cacheOptions, url, method } = request;
const { cacheOptions } = request;
return (
!willFetch &&
(cacheOptions?.backgroundReload ||
(store.lifetimes && lid && url && method ? store.lifetimes.isSoftExpired(lid, url, method as HTTPMethod) : false))
(store.lifetimes && identifier ? store.lifetimes.isSoftExpired(identifier) : false))
);
}

Expand Down Expand Up @@ -120,34 +121,36 @@ function fetchContentAndHydrate<T>(
) as Promise<T>;
}

export const SkipCache = Symbol.for('ember-data:skip-cache');
export const EnableHydration = Symbol.for('ember-data:enable-hydration');

export const CacheHandler: Handler = {
request<T>(context: StoreRequestContext, next: NextFn<T>): Promise<T> | Future<T> {
// if we have no cache or no cache-key skip cache handling
if (!context.request.store || !(context.request.cacheOptions?.key || context.request.url)) {
if (!context.request.store || context.request.cacheOptions?.[SkipCache]) {
return next(context.request);
}

const { store } = context.request;
const { cacheOptions, url, method } = context.request;
const lid = cacheOptions?.key || (method === 'GET' && url) ? url : null;
const peeked = lid ? store.cache.peekRequest({ lid }) : null;
const identifier = store.identifierCache.getOrCreateDocumentIdentifier(context.request);

const peeked = identifier ? store.cache.peekRequest(identifier) : null;

// determine if we should skip cache
if (calcShouldFetch(store, context.request, !!peeked, lid)) {
if (calcShouldFetch(store, context.request, !!peeked, identifier)) {
return fetchContentAndHydrate(next, context, true, false);
}

// if we have not skipped cache, determine if we should update behind the scenes
if (calcShouldBackgroundFetch(store, context.request, false, lid)) {
if (calcShouldBackgroundFetch(store, context.request, false, identifier)) {
void fetchContentAndHydrate(next, context, false, true);
}

if ('error' in peeked!) {
throw peeked.error;
}

const shouldHydrate: boolean =
(context.request[Symbol.for('ember-data:enable-hydration')] as boolean | undefined) || false;
const shouldHydrate: boolean = (context.request[EnableHydration] as boolean | undefined) || false;

return Promise.resolve(
shouldHydrate
Expand Down
51 changes: 39 additions & 12 deletions packages/store/src/-private/caches/identifier-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,39 @@ export function setIdentifierResetMethod(method: ResetMethod | null): void {
type WithLid = { lid: string };
type WithId = { id: string | null; type: string };

function defaultGenerationMethod(data: ResourceData | { type: string }, bucket: IdentifierBucket): string {
if (isNonEmptyString((data as WithLid).lid)) {
return (data as WithLid).lid;
}
if ((data as WithId).id !== undefined) {
let { type, id } = data as WithId;
// TODO: add test for id not a string
if (isNonEmptyString(coerceId(id))) {
return `@lid:${normalizeModelName(type)}-${id}`;
function assertIsRequest(request: unknown): asserts request is ImmutableRequestInfo {
return;
}

function defaultGenerationMethod(data: ImmutableRequestInfo, bucket: 'document'): string | null;
function defaultGenerationMethod(data: ResourceData | { type: string }, bucket: 'record'): string;
function defaultGenerationMethod(
data: ImmutableRequestInfo | ResourceData | { type: string },
bucket: IdentifierBucket
): string | null {
if (bucket === 'record') {
if (isNonEmptyString((data as WithLid).lid)) {
return (data as WithLid).lid;
}
if ((data as WithId).id !== undefined) {
let { type, id } = data as WithId;
// TODO: add test for id not a string
if (isNonEmptyString(coerceId(id))) {
return `@lid:${normalizeModelName(type)}-${id}`;
}
}
return uuidv4();
} else if (bucket === 'document') {
assertIsRequest(data);
if (!data.url) {
return null;
}
if (!data.method || data.method.toUpperCase() === 'GET') {
return data.url;
}
return null;
}
return uuidv4();
assert(`Unknown bucket ${bucket}`, false);
}

function defaultEmptyCallback(...args: any[]): any {}
Expand Down Expand Up @@ -150,7 +171,7 @@ export class IdentifierCache {
constructor() {
// we cache the user configuredGenerationMethod at init because it must
// be configured prior and is not allowed to be changed
this._generate = configuredGenerationMethod || defaultGenerationMethod;
this._generate = configuredGenerationMethod || (defaultGenerationMethod as GenerationMethod);
this._update = configuredUpdateMethod || defaultEmptyCallback;
this._forget = configuredForgetMethod || defaultEmptyCallback;
this._reset = configuredResetMethod || defaultEmptyCallback;
Expand Down Expand Up @@ -346,10 +367,16 @@ export class IdentifierCache {
@public
*/
getOrCreateDocumentIdentifier(request: ImmutableRequestInfo): StableDocumentIdentifier | null {
const cacheKey = request.cacheOptions?.key || request.url;
let cacheKey: string | null | undefined = request.cacheOptions?.key;

if (!cacheKey) {
cacheKey = this._generate(request, 'document');
}

if (!cacheKey) {
return null;
}

let identifier = this._cache.documents.get(cacheKey);

if (identifier === undefined) {
Expand Down
Loading

0 comments on commit e316aec

Please sign in to comment.