UNPKG

@data-client/core

Version:

Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch

868 lines (790 loc) 24.8 kB
import type { ErrorTypes, SnapshotInterface, Schema, Denormalize, Queryable, SchemaArgs, } from '@data-client/normalizr'; import { ExpiryStatus, EndpointInterface, FetchFunction, ResolveType, DenormalizeNullable, EntityPath, MemoCache, isEntity, denormalize, validateQueryKey, } from '@data-client/normalizr'; import AbortOptimistic from './AbortOptimistic.js'; import { createUnsubscription, createSubscription, } from './actions/createSubscription.js'; import { createExpireAll, createFetch, createInvalidate, createInvalidateAll, createReset, createSet, createSetResponse, } from './actions/index.js'; import ensurePojo from './ensurePojo.js'; import type { EndpointUpdateFunction } from './types.js'; import { ReduxMiddlewareAPI } from '../manager/applyManager.js'; import type { GCInterface } from '../state/GCPolicy.js'; import { ImmortalGCPolicy } from '../state/GCPolicy.js'; import { initialState } from '../state/reducer/createReducer.js'; import selectMeta from '../state/selectMeta.js'; import type { ActionTypes, Dispatch, State } from '../types.js'; export type GenericDispatch = (value: any) => Promise<void>; export type DataClientDispatch = (value: ActionTypes) => Promise<void>; export interface ControllerConstructorProps< D extends GenericDispatch = DataClientDispatch, > { dispatch?: D; getState?: () => State<unknown>; memo?: Pick<MemoCache, 'denormalize' | 'query' | 'buildQueryKey'>; gcPolicy?: GCInterface; } const unsetDispatch = (action: unknown): Promise<void> => { throw new Error( `Dispatching while constructing your middleware is not allowed. ` + `Other middleware would not be applied to this dispatch.`, ); }; const unsetState = (): State<unknown> => { // This is only the value until it is set by the DataProvider /* istanbul ignore next */ return initialState; }; /** * Imperative control of Reactive Data Client store * @see https://dataclient.io/docs/api/Controller */ export default class Controller< // NOTE: We template on entire dispatch, so we can be contravariant on ActionTypes D extends GenericDispatch = DataClientDispatch, > { /** * Dispatches an action to Reactive Data Client reducer. * * @see https://dataclient.io/docs/api/Controller#dispatch */ declare protected _dispatch: D; /** * Gets the latest state snapshot that is fully committed. * * This can be useful for imperative use-cases like event handlers. * This should *not* be used to render; instead useSuspense() or useCache() * @see https://dataclient.io/docs/api/Controller#getState */ declare getState: () => State<unknown>; /** * Singleton to maintain referential equality between calls */ declare readonly memo: Pick< MemoCache, 'denormalize' | 'query' | 'buildQueryKey' >; /** * Handles garbage collection */ declare readonly gcPolicy: GCInterface; constructor({ dispatch = unsetDispatch as any, getState = unsetState, memo = new MemoCache(), gcPolicy = new ImmortalGCPolicy(), }: ControllerConstructorProps<D> = {}) { this._dispatch = dispatch; this.getState = getState; this.memo = memo; this.gcPolicy = gcPolicy; } // TODO: drop when drop support for destructuring (0.14 and below) set dispatch(dispatch: D) { /* istanbul ignore next */ this._dispatch = dispatch; } // TODO: drop when drop support for destructuring (0.14 and below) get dispatch(): D { return this._dispatch; } bindMiddleware({ dispatch, getState, }: { dispatch: D; getState: ReduxMiddlewareAPI['getState']; }) { this._dispatch = dispatch; this.getState = getState; } /*************** Action Dispatchers ***************/ /** * Fetches the endpoint with given args, updating the Reactive Data Client cache with the response or error upon completion. * @see https://dataclient.io/docs/api/Controller#fetch */ fetch = < E extends EndpointInterface & { update?: EndpointUpdateFunction<E> }, >( endpoint: E, ...args: readonly [...Parameters<E>] ): E['schema'] extends undefined | null ? ReturnType<E> : Promise<Denormalize<E['schema']>> => { const action = createFetch(endpoint, { args, }); this.dispatch(action); if (endpoint.schema) { return action.meta.promise.then(input => denormalize(endpoint.schema, input, {}, args), ) as any; } return action.meta.promise as any; }; /** * Fetches only if endpoint is considered 'stale'; otherwise returns undefined * @see https://dataclient.io/docs/api/Controller#fetchIfStale */ fetchIfStale = < E extends EndpointInterface & { update?: EndpointUpdateFunction<E> }, >( endpoint: E, ...args: readonly [...Parameters<E>] ): E['schema'] extends undefined | null ? ReturnType<E> | ResolveType<E> : Promise<Denormalize<E['schema']>> | Denormalize<E['schema']> => { const { data, expiresAt, expiryStatus } = this.getResponse( endpoint, ...args, this.getState(), ); if (expiryStatus !== ExpiryStatus.Invalid && Date.now() <= expiresAt) return data as any; return this.fetch(endpoint, ...args); }; /** * Forces refetching and suspense on useSuspense with the same Endpoint and parameters. * @see https://dataclient.io/docs/api/Controller#invalidate */ invalidate = <E extends EndpointInterface>( endpoint: E, ...args: readonly [...Parameters<E>] | readonly [null] ): Promise<void> => args[0] !== null ? this.dispatch( createInvalidate(endpoint, { args: args as Parameters<E>, }), ) : Promise.resolve(); /** * Forces refetching and suspense on useSuspense on all matching endpoint result keys. * @see https://dataclient.io/docs/api/Controller#invalidateAll * @returns Promise that resolves when invalidation is commited. */ invalidateAll = (options: { testKey: (key: string) => boolean }) => this.dispatch(createInvalidateAll((key: string) => options.testKey(key))); /** * Sets all matching endpoint result keys to be STALE. * @see https://dataclient.io/docs/api/Controller#expireAll * @returns Promise that resolves when expiry is commited. *NOT* fetch promise */ expireAll = (options: { testKey: (key: string) => boolean }) => this.dispatch(createExpireAll((key: string) => options.testKey(key))); /** * Resets the entire Reactive Data Client cache. All inflight requests will not resolve. * @see https://dataclient.io/docs/api/Controller#resetEntireStore */ resetEntireStore = (): Promise<void> => this.dispatch(createReset()); /** * Sets value for the Queryable and args. * @see https://dataclient.io/docs/api/Controller#set */ set<S extends Queryable>( schema: S, ...rest: readonly [...SchemaArgs<S>, (previousValue: Denormalize<S>) => {}] ): Promise<void>; set<S extends Queryable>( schema: S, ...rest: readonly [...SchemaArgs<S>, {}] ): Promise<void>; set<S extends Queryable>( schema: S, ...rest: readonly [...SchemaArgs<S>, any] ): Promise<void> { const value = rest[rest.length - 1]; const action = createSet(schema, { args: rest.slice(0, rest.length - 1) as SchemaArgs<S>, value, }); // TODO: reject with error if this fails in reducer return this.dispatch(action); } /** * Sets response for the Endpoint and args. * @see https://dataclient.io/docs/api/Controller#setResponse */ setResponse = < E extends EndpointInterface & { update?: EndpointUpdateFunction<E>; }, >( endpoint: E, ...rest: readonly [...Parameters<E>, any] ): Promise<void> => { const response: ResolveType<E> = rest[rest.length - 1]; const action = createSetResponse(endpoint, { args: rest.slice(0, rest.length - 1) as Parameters<E>, response, }); return this.dispatch(action); }; /** * Sets an error response for the Endpoint and args. * @see https://dataclient.io/docs/api/Controller#setError */ setError = < E extends EndpointInterface & { update?: EndpointUpdateFunction<E>; }, >( endpoint: E, ...rest: readonly [...Parameters<E>, Error] ): Promise<void> => { const response: Error = rest[rest.length - 1]; const action = createSetResponse(endpoint, { args: rest.slice(0, rest.length - 1) as Parameters<E>, response, error: true, }); return this.dispatch(action); }; /** * Resolves an inflight fetch. * @see https://dataclient.io/docs/api/Controller#resolve */ resolve = < E extends EndpointInterface & { update?: EndpointUpdateFunction<E>; }, >( endpoint: E, meta: | { args: readonly [...Parameters<E>]; response: Error; fetchedAt: number; error: true; } | { args: readonly [...Parameters<E>]; response: any; fetchedAt: number; error?: false | undefined; }, ): Promise<void> => { return this.dispatch(createSetResponse(endpoint, meta as any)); }; /** * Marks a new subscription to a given Endpoint. * @see https://dataclient.io/docs/api/Controller#subscribe */ subscribe = < E extends EndpointInterface< FetchFunction, Schema | undefined, undefined | false >, >( endpoint: E, ...args: readonly [...Parameters<E>] | readonly [null] ): Promise<void> => args[0] !== null ? this.dispatch( createSubscription(endpoint, { args: args as Parameters<E>, }), ) : Promise.resolve(); /** * Marks completion of subscription to a given Endpoint. * @see https://dataclient.io/docs/api/Controller#unsubscribe */ unsubscribe = < E extends EndpointInterface< FetchFunction, Schema | undefined, undefined | false >, >( endpoint: E, ...args: readonly [...Parameters<E>] | readonly [null] ): Promise<void> => args[0] !== null ? this.dispatch( createUnsubscription(endpoint, { args: args as Parameters<E>, }), ) : Promise.resolve(); /*************** More ***************/ /* TODO: abort = <E extends EndpointInterface>( endpoint: E, ...args: readonly [...Parameters<E>] ): Promise<void> */ /** * Gets a snapshot (https://dataclient.io/docs/api/Snapshot) * @see https://dataclient.io/docs/api/Controller#snapshot */ snapshot = (state: State<unknown>, fetchedAt?: number): Snapshot<unknown> => { return new Snapshot(this, state, fetchedAt); }; /** * Gets the error, if any, for a given endpoint. Returns undefined for no errors. * @see https://dataclient.io/docs/api/Controller#getError */ getError<E extends EndpointInterface>( endpoint: E, ...rest: | readonly [null, State<unknown>] | readonly [...Parameters<E>, State<unknown>] ): ErrorTypes | undefined; getError<E extends Pick<EndpointInterface, 'key'>>( endpoint: E, ...rest: | readonly [null, State<unknown>] | readonly [...Parameters<E['key']>, State<unknown>] ): ErrorTypes | undefined; getError( endpoint: EndpointInterface, ...rest: readonly [...unknown[], State<unknown>] ): ErrorTypes | undefined { if (rest[0] === null) return; const state = rest[rest.length - 1] as State<unknown>; // this is typescript generics breaking const args: any = rest.slice(0, rest.length - 1); const key = endpoint.key(...args); const meta = selectMeta(state, key); const error = state.endpoints[key]; if (error !== undefined && meta?.errorPolicy === 'soft') return; return meta?.error as any; } /** * Gets the (globally referentially stable) response for a given endpoint/args pair from state given. * @see https://dataclient.io/docs/api/Controller#getResponse */ getResponse<E extends EndpointInterface>( endpoint: E, ...rest: | readonly [null, State<unknown>] | readonly [...Parameters<E>, State<unknown>] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; countRef: () => () => void; }; getResponse< E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>, >( endpoint: E, ...rest: readonly [ ...(readonly [...Parameters<E['key']>] | readonly [null]), State<unknown>, ] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; countRef: () => () => void; }; getResponse( endpoint: EndpointInterface, ...rest: readonly [...unknown[], State<unknown>] ): { data: unknown; expiryStatus: ExpiryStatus; expiresAt: number; countRef: () => () => void; } { // TODO: breaking: only return data return this.getResponseMeta(endpoint, ...rest); } /** * Gets the (globally referentially stable) response for a given endpoint/args pair from state given. * @see https://dataclient.io/docs/api/Controller#getResponseMeta */ getResponseMeta<E extends EndpointInterface>( endpoint: E, ...rest: | readonly [null, State<unknown>] | readonly [...Parameters<E>, State<unknown>] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; countRef: () => () => void; }; getResponseMeta< E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>, >( endpoint: E, ...rest: readonly [ ...(readonly [...Parameters<E['key']>] | readonly [null]), State<unknown>, ] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; countRef: () => () => void; }; getResponseMeta( endpoint: EndpointInterface, ...rest: readonly [...unknown[], State<unknown>] ): { data: unknown; expiryStatus: ExpiryStatus; expiresAt: number; countRef: () => () => void; } { const state = rest[rest.length - 1] as State<unknown>; // this is typescript generics breaking const args: any = rest .slice(0, rest.length - 1) // handle FormData .map(ensurePojo); const isActive = args.length !== 1 || args[0] !== null; const key = isActive ? endpoint.key(...args) : ''; const cacheEndpoints = isActive ? state.endpoints[key] : undefined; const schema = endpoint.schema; const meta = selectMeta(state, key); let expiresAt = meta?.expiresAt; // if we have no endpoint entry, and our endpoint has a schema - try querying the store const shouldQuery = cacheEndpoints === undefined && schema !== undefined; const input = shouldQuery ? // nothing in endpoints cache, so try querying if we have a schema to do so this.memo.buildQueryKey( schema, args, state.entities as any, state.indexes, key, ) : cacheEndpoints; if (!isActive) { // when not active simply return the query input without denormalizing return { data: input as any, expiryStatus: ExpiryStatus.Valid, expiresAt: Infinity, countRef: () => () => undefined, }; } let isInvalid = false; if (shouldQuery) { isInvalid = !validateQueryKey(input); // endpoint without entities } else if (!schema || !schemaHasEntity(schema)) { return { data: cacheEndpoints, expiryStatus: meta?.invalidated ? ExpiryStatus.Invalid : cacheEndpoints && !endpoint.invalidIfStale ? ExpiryStatus.Valid : ExpiryStatus.InvalidIfStale, expiresAt: expiresAt || 0, countRef: this.gcPolicy.createCountRef({ key }), }; } // second argument is false if any entities are missing const { data, paths } = this.memo.denormalize( schema, input, state.entities, args, ) as { data: any; paths: EntityPath[] }; // note: isInvalid can only be true if shouldQuery is true if (!expiresAt && isInvalid) expiresAt = 1; return this.getSchemaResponse( data, key, paths, state.entityMeta, expiresAt, endpoint.invalidIfStale || isInvalid, meta, ); } /** * Queries the store for a Querable schema * @see https://dataclient.io/docs/api/Controller#get */ get<S extends Queryable>( schema: S, ...rest: readonly [ ...SchemaArgs<S>, Pick<State<unknown>, 'entities' | 'entityMeta'>, ] ): DenormalizeNullable<S> | undefined { const state = rest[rest.length - 1] as State<any>; // this is typescript generics breaking const args: any = rest .slice(0, rest.length - 1) .map(ensurePojo) as SchemaArgs<S>; return this.memo.query(schema, args, state.entities as any, state.indexes); } /** * Queries the store for a Querable schema; providing related metadata * @see https://dataclient.io/docs/api/Controller#getQueryMeta */ getQueryMeta<S extends Queryable>( schema: S, ...rest: readonly [ ...SchemaArgs<S>, Pick<State<unknown>, 'entities' | 'entityMeta'>, ] ): { data: DenormalizeNullable<S> | undefined; countRef: () => () => void; } { const state = rest[rest.length - 1] as State<any>; // this is typescript generics breaking const args: any = rest .slice(0, rest.length - 1) .map(ensurePojo) as SchemaArgs<S>; // TODO: breaking: Switch back to this.memo.query(schema, args, state.entities as any, state.indexes) to do // this logic const input = this.memo.buildQueryKey( schema, args, state.entities as any, state.indexes, JSON.stringify(args), ); if (!input) { return { data: undefined, countRef: () => () => undefined }; } const { data, paths } = this.memo.denormalize( schema, input, state.entities, args, ); return { data: typeof data === 'symbol' ? undefined : (data as any), countRef: this.gcPolicy.createCountRef({ paths }), }; } private getSchemaResponse<T>( data: T, key: string, paths: EntityPath[], entityMeta: State<unknown>['entityMeta'], expiresAt: number, invalidIfStale: boolean, meta: { error?: unknown; invalidated?: unknown } = {}, ): { data: T; expiryStatus: ExpiryStatus; expiresAt: number; countRef: () => () => void; } { const invalidDenormalize = typeof data === 'symbol'; // fallback to entity expiry time if (!expiresAt) { expiresAt = entityExpiresAt(paths, entityMeta); } // https://dataclient.io/docs/concepts/expiry-policy#expiry-status // we don't track the difference between stale or fresh because that is tied to triggering // conditions const expiryStatus = meta?.invalidated || (invalidDenormalize && !meta?.error) ? ExpiryStatus.Invalid : invalidDenormalize || invalidIfStale ? ExpiryStatus.InvalidIfStale : ExpiryStatus.Valid; return { data, expiryStatus, expiresAt, countRef: this.gcPolicy.createCountRef({ key, paths }), }; } } // benchmark: https://www.measurethat.net/Benchmarks/Show/24691/0/min-reducer-vs-imperative-with-paths // earliest expiry dictates age function entityExpiresAt( paths: EntityPath[], entityMeta: { readonly [entityKey: string]: { readonly [pk: string]: { readonly date: number; readonly expiresAt: number; readonly fetchedAt: number; // This is only the value until it is set by the DataProvider }; }; }, ) { let expiresAt = Infinity; for (const { pk, key } of paths) { const entityExpiry = entityMeta[key]?.[pk]?.expiresAt; // expiresAt will always resolve to false with any comparison if (entityExpiry < expiresAt) expiresAt = entityExpiry; } return expiresAt; } /** Determine whether the schema has any entities. * * Without entities, denormalization is not needed, and results should not be queried. */ function schemaHasEntity(schema: Schema): boolean { if (isEntity(schema)) return true; if (Array.isArray(schema)) return schema.length !== 0 && schemaHasEntity(schema[0]); if (schema && (typeof schema === 'object' || typeof schema === 'function')) { const nestedSchema = 'schema' in schema ? (schema.schema as Record<string, Schema>) : schema; if (typeof nestedSchema === 'function') { return schemaHasEntity(nestedSchema); } return Object.values(nestedSchema).some(x => schemaHasEntity(x)); } return false; } export type { ErrorTypes }; class Snapshot<T = unknown> implements SnapshotInterface { static readonly abort = new AbortOptimistic(); private state: State<T>; private controller: Controller; readonly fetchedAt: number; readonly abort = Snapshot.abort; constructor(controller: Controller, state: State<T>, fetchedAt = 0) { this.state = state; this.controller = controller; this.fetchedAt = fetchedAt; } /*************** Data Access ***************/ /** @see https://dataclient.io/docs/api/Snapshot#getResponse */ getResponse<E extends EndpointInterface>( endpoint: E, ...args: readonly [null] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; }; getResponse<E extends EndpointInterface>( endpoint: E, ...args: readonly [...Parameters<E>] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; }; getResponse< E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>, >( endpoint: E, ...args: readonly [...Parameters<E['key']>] | readonly [null] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; }; getResponse< E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>, >( endpoint: E, ...args: readonly [...Parameters<E['key']>] | readonly [null] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; } { return this.controller.getResponse(endpoint, ...args, this.state); } /** @see https://dataclient.io/docs/api/Snapshot#getResponseMeta */ getResponseMeta<E extends EndpointInterface>( endpoint: E, ...args: readonly [null] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; }; getResponseMeta<E extends EndpointInterface>( endpoint: E, ...args: readonly [...Parameters<E>] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; }; getResponseMeta< E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>, >( endpoint: E, ...args: readonly [...Parameters<E['key']>] | readonly [null] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; }; getResponseMeta< E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>, >( endpoint: E, ...args: readonly [...Parameters<E['key']>] | readonly [null] ): { data: DenormalizeNullable<E['schema']>; expiryStatus: ExpiryStatus; expiresAt: number; } { return this.controller.getResponseMeta(endpoint, ...args, this.state); } /** @see https://dataclient.io/docs/api/Snapshot#getError */ getError<E extends EndpointInterface>( endpoint: E, ...args: readonly [...Parameters<E>] | readonly [null] ): ErrorTypes | undefined; getError<E extends Pick<EndpointInterface, 'key'>>( endpoint: E, ...args: readonly [...Parameters<E['key']>] | readonly [null] ): ErrorTypes | undefined; getError<E extends Pick<EndpointInterface, 'key'>>( endpoint: E, ...args: readonly [...Parameters<E['key']>] | readonly [null] ): ErrorTypes | undefined { return this.controller.getError(endpoint, ...args, this.state); } /** * Retrieved memoized value for any Querable schema * @see https://dataclient.io/docs/api/Snapshot#get */ get<S extends Queryable>( schema: S, ...args: SchemaArgs<S> ): DenormalizeNullable<S> | undefined { return this.controller.get(schema, ...args, this.state); } /** * Queries the store for a Querable schema; providing related metadata * @see https://dataclient.io/docs/api/Snapshot#getQueryMeta */ getQueryMeta<S extends Queryable>( schema: S, ...args: SchemaArgs<S> ): { data: DenormalizeNullable<S> | undefined; countRef: () => () => void; } { return this.controller.getQueryMeta(schema, ...args, this.state); } }