@data-client/core
Version:
Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch
1,640 lines (1,524 loc) • 56.8 kB
JavaScript
'use strict';
var normalizr = require('@data-client/normalizr');
function expireReducer(state, action) {
const meta = {
...state.meta
};
Object.keys(meta).forEach(key => {
if (action.testKey(key)) {
meta[key] = {
...meta[key],
// 1 instead of 0 so we can do 'falsy' checks to see if it is set
expiresAt: 1
};
}
});
return {
...state,
meta
};
}
function createMeta(expiryLength, fetchedAt) {
const now = Date.now();
return {
fetchedAt: fetchedAt != null ? fetchedAt : now,
date: now,
expiresAt: now + expiryLength
};
}
const FETCH = 'rdc/fetch';
const SET = 'rdc/set';
const SET_RESPONSE = 'rdc/setresponse';
const OPTIMISTIC = 'rdc/optimistic';
const RESET = 'rdc/reset';
const SUBSCRIBE = 'rdc/subscribe';
const UNSUBSCRIBE = 'rdc/unsubscribe';
const INVALIDATE = 'rdc/invalidate';
const INVALIDATEALL = 'rdc/invalidateall';
const EXPIREALL = 'rdc/expireall';
const GC = 'rdc/gc';
const FETCH_TYPE = FETCH;
const SET_TYPE = SET;
const SET_RESPONSE_TYPE = SET_RESPONSE;
const OPTIMISTIC_TYPE = OPTIMISTIC;
const RESET_TYPE = RESET;
const SUBSCRIBE_TYPE = SUBSCRIBE;
const UNSUBSCRIBE_TYPE = UNSUBSCRIBE;
const INVALIDATE_TYPE = INVALIDATE;
const INVALIDATEALL_TYPE = INVALIDATEALL;
const EXPIREALL_TYPE = EXPIREALL;
const GC_TYPE = GC;
var actionTypes = /*#__PURE__*/Object.freeze({
__proto__: null,
EXPIREALL: EXPIREALL,
EXPIREALL_TYPE: EXPIREALL_TYPE,
FETCH: FETCH,
FETCH_TYPE: FETCH_TYPE,
GC: GC,
GC_TYPE: GC_TYPE,
INVALIDATE: INVALIDATE,
INVALIDATEALL: INVALIDATEALL,
INVALIDATEALL_TYPE: INVALIDATEALL_TYPE,
INVALIDATE_TYPE: INVALIDATE_TYPE,
OPTIMISTIC: OPTIMISTIC,
OPTIMISTIC_TYPE: OPTIMISTIC_TYPE,
RESET: RESET,
RESET_TYPE: RESET_TYPE,
SET: SET,
SET_RESPONSE: SET_RESPONSE,
SET_RESPONSE_TYPE: SET_RESPONSE_TYPE,
SET_TYPE: SET_TYPE,
SUBSCRIBE: SUBSCRIBE,
SUBSCRIBE_TYPE: SUBSCRIBE_TYPE,
UNSUBSCRIBE: UNSUBSCRIBE,
UNSUBSCRIBE_TYPE: UNSUBSCRIBE_TYPE
});
function createOptimistic(endpoint, args, fetchedAt) {
var _endpoint$dataExpiryL, _endpoint$dataExpiryL2;
/* istanbul ignore next */
if (process.env.NODE_ENV === 'development' && ((_endpoint$dataExpiryL = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL : 0) < 0) {
throw new Error('Negative expiry length are not allowed.');
}
return {
type: OPTIMISTIC,
key: endpoint.key(...args),
args,
endpoint,
meta: createMeta((_endpoint$dataExpiryL2 = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL2 : 60000, fetchedAt)
};
}
function fetchReducer(state, action) {
if (action.endpoint.getOptimisticResponse && action.endpoint.sideEffect) {
const setAction = createOptimistic(action.endpoint, action.args, action.meta.fetchedAt);
return {
...state,
optimistic: [...state.optimistic, setAction]
};
}
return state;
}
function invalidateReducer(state, action) {
const endpoints = {
...state.endpoints
};
const meta = {
...state.meta
};
const invalidateKey = key => {
delete endpoints[key];
const itemMeta = {
...meta[key],
expiresAt: 0,
invalidated: true
};
delete itemMeta.error;
meta[key] = itemMeta;
};
if (action.type === INVALIDATE) {
invalidateKey(action.key);
} else {
Object.keys(endpoints).forEach(key => {
if (action.testKey(key)) {
invalidateKey(key);
}
});
}
return {
...state,
endpoints,
meta
};
}
function setReducer(state, action, controller) {
let value;
if (typeof action.value === 'function') {
const previousValue = controller.get(action.schema, ...action.args, state);
if (previousValue === undefined) return state;
value = action.value(previousValue);
} else {
value = action.value;
}
try {
const {
entities,
indexes,
entityMeta
} = normalizr.normalize(action.schema, value, action.args, state, action.meta);
return {
entities,
endpoints: state.endpoints,
indexes,
meta: state.meta,
entityMeta,
optimistic: state.optimistic,
lastReset: state.lastReset
};
// reducer must update the state, so in case of processing errors we simply compute the endpoints inline
} catch (error) {
// this is not always bubbled up, so let's double sure this doesn't fail silently
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
console.error(error);
}
return state;
}
}
class AbortOptimistic extends Error {}
function setResponseReducer(state, action, controller) {
if (action.error) {
return reduceError(state, action, action.response);
}
try {
var _state$meta$action$ke;
let response;
// for true set's response is contained in action
if (action.type === OPTIMISTIC) {
// this should never happen
/* istanbul ignore if */
if (!action.endpoint.getOptimisticResponse) return state;
try {
// compute optimistic response based on current state
response = action.endpoint.getOptimisticResponse.call(action.endpoint, controller.snapshot(state, action.meta.fetchedAt), ...action.args);
} catch (e) {
// AbortOptimistic means 'do nothing', otherwise we count the exception as endpoint failure
if (e.constructor === AbortOptimistic) {
return state;
}
throw e;
}
} else {
response = action.response;
}
const {
result,
entities,
indexes,
entityMeta
} = normalizr.normalize(action.endpoint.schema, response, action.args, state, action.meta);
const endpoints = {
...state.endpoints,
[action.key]: result
};
try {
if (action.endpoint.update) {
const updaters = action.endpoint.update(result, ...action.args);
Object.keys(updaters).forEach(key => {
endpoints[key] = updaters[key](endpoints[key]);
});
}
// no reason to completely fail because of user-code error
// integrity of this state update is still guaranteed
} catch (error) {
console.error(`The following error occured during Endpoint.update() for ${action.key}`);
console.error(error);
}
return {
entities,
endpoints,
indexes,
meta: {
...state.meta,
[action.key]: {
date: action.meta.date,
fetchedAt: action.meta.fetchedAt,
expiresAt: action.meta.expiresAt,
prevExpiresAt: (_state$meta$action$ke = state.meta[action.key]) == null ? void 0 : _state$meta$action$ke.expiresAt
}
},
entityMeta,
optimistic: filterOptimistic(state, action),
lastReset: state.lastReset
};
// reducer must update the state, so in case of processing errors we simply compute the endpoints inline
} catch (error) {
if (typeof error === 'object') {
error.message = `Error processing ${action.key}\n\nFull Schema: ${JSON.stringify(action.endpoint.schema, undefined, 2)}\n\nError:\n${error.message}`;
if ('response' in action) error.response = action.response;
error.status = 400;
}
// this is not always bubbled up, so let's double sure this doesn't fail silently
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
console.error(error);
}
return reduceError(state, action, error);
}
}
function reduceError(state, action, error) {
if (error.name === 'AbortError') {
// In case we abort simply undo the optimistic update and act like no fetch even occured
// We still want those watching promises from fetch directly to observed the abort, but we don't want to
// Trigger errors in this case. This means theoretically improperly built abortes useResource() could suspend forever.
return {
...state,
optimistic: filterOptimistic(state, action)
};
}
return {
...state,
meta: {
...state.meta,
[action.key]: {
date: action.meta.date,
fetchedAt: action.meta.fetchedAt,
expiresAt: action.meta.expiresAt,
error,
errorPolicy: action.endpoint.errorPolicy == null ? void 0 : action.endpoint.errorPolicy(error)
}
},
optimistic: filterOptimistic(state, action)
};
}
/** Filter all requests with same serialization that did not start after the resolving request */
function filterOptimistic(state, resolvingAction) {
return state.optimistic.filter(optimisticAction => optimisticAction.key !== resolvingAction.key || (optimisticAction.type === OPTIMISTIC ? optimisticAction.meta.fetchedAt !== resolvingAction.meta.fetchedAt : optimisticAction.meta.date > resolvingAction.meta.date));
}
function createReducer(controller) {
return function reducer(state, action) {
if (!state) state = initialState;
switch (action.type) {
case GC:
// inline deletes are fine as these should have 0 refcounts
action.entities.forEach(({
key,
pk
}) => {
var _entities$key, _entityMeta$key;
(_entities$key = state.entities[key]) == null || delete _entities$key[pk];
(_entityMeta$key = state.entityMeta[key]) == null || delete _entityMeta$key[pk];
});
action.endpoints.forEach(fetchKey => {
delete state.endpoints[fetchKey];
delete state.meta[fetchKey];
});
return state;
case FETCH:
return fetchReducer(state, action);
case OPTIMISTIC:
// eslint-disable-next-line no-fallthrough
case SET_RESPONSE:
return setResponseReducer(state, action, controller);
case SET:
return setReducer(state, action, controller);
case INVALIDATEALL:
case INVALIDATE:
return invalidateReducer(state, action);
case EXPIREALL:
return expireReducer(state, action);
case RESET:
return {
...initialState,
lastReset: action.date
};
default:
// A reducer must always return a valid state.
// Alternatively you can throw an error if an invalid action is dispatched.
return state;
}
};
}
const initialState = {
entities: {},
endpoints: {},
indexes: {},
meta: {},
entityMeta: {},
optimistic: [],
lastReset: 0
};
var internal = /*#__PURE__*/Object.freeze({
__proto__: null,
INVALID: normalizr.INVALID,
MemoCache: normalizr.MemoCache,
initialState: initialState
});
function createSubscription(endpoint, {
args
}) {
return {
type: SUBSCRIBE,
key: endpoint.key(...args),
args,
endpoint
};
}
function createUnsubscription(endpoint, {
args
}) {
return {
type: UNSUBSCRIBE,
key: endpoint.key(...args),
args,
endpoint
};
}
const ensurePojo =
// FormData doesn't exist in node
/* istanbul ignore else we don't run coverage when we test node*/
typeof FormData !== 'undefined' ? body => body instanceof FormData ? Object.fromEntries(body.entries()) : body : /* istanbul ignore next */body => body;
function createSetResponse(endpoint, {
args,
fetchedAt,
response,
error = false
}) {
var _endpoint$errorExpiry, _endpoint$dataExpiryL;
const expiryLength = error ? (_endpoint$errorExpiry = endpoint.errorExpiryLength) != null ? _endpoint$errorExpiry : 1000 : (_endpoint$dataExpiryL = endpoint.dataExpiryLength) != null ? _endpoint$dataExpiryL : 60000;
/* istanbul ignore next */
if (process.env.NODE_ENV === 'development' && expiryLength < 0) {
throw new Error('Negative expiry length are not allowed.');
}
return {
type: SET_RESPONSE,
key: endpoint.key(...args),
response,
args: args.map(ensurePojo),
endpoint,
meta: createMeta(expiryLength, fetchedAt),
error
};
}
function createSet(schema, {
args,
fetchedAt,
value
}) {
return {
type: SET,
value,
args: args.map(ensurePojo),
schema,
meta: createMeta(60000, fetchedAt)
};
}
function createReset() {
return {
type: RESET,
date: Date.now()
};
}
function createInvalidateAll(testKey) {
return {
type: INVALIDATEALL,
testKey
};
}
function createInvalidate(endpoint, {
args
}) {
return {
type: INVALIDATE,
key: endpoint.key(...args)
};
}
/**
* Requesting a fetch to begin
*/
function createFetch(endpoint, {
args
}) {
let resolve = 0;
let reject = 0;
const promise = new Promise((a, b) => {
[resolve, reject] = [a, b];
});
const meta = {
fetchedAt: Date.now(),
resolve,
reject,
promise
};
return {
type: FETCH,
key: endpoint.key(...args),
args,
endpoint,
meta
};
}
function createExpireAll(testKey) {
return {
type: EXPIREALL,
testKey
};
}
var index = /*#__PURE__*/Object.freeze({
__proto__: null,
createExpireAll: createExpireAll,
createFetch: createFetch,
createInvalidate: createInvalidate,
createInvalidateAll: createInvalidateAll,
createMeta: createMeta,
createOptimistic: createOptimistic,
createReset: createReset,
createSet: createSet,
createSetResponse: createSetResponse,
createSubscription: createSubscription,
createUnsubscription: createUnsubscription
});
class GCPolicy {
endpointCount = new Map();
entityCount = new Map();
endpointsQ = new Set();
entitiesQ = [];
constructor({
// every 5 min
intervalMS = 60 * 1000 * 5,
expiryMultiplier = 2,
expiresAt
} = {}) {
if (expiresAt) {
this.expiresAt = expiresAt.bind(this);
}
this.options = {
intervalMS,
expiryMultiplier
};
}
init(controller) {
this.controller = controller;
this.intervalId = setInterval(() => {
this.idleCallback(() => this.runSweep(), {
timeout: 1000
});
}, this.options.intervalMS);
}
cleanup() {
clearInterval(this.intervalId);
}
createCountRef({
key,
paths = []
}) {
// increment
return () => {
var _this$endpointCount$g;
if (key) this.endpointCount.set(key, ((_this$endpointCount$g = this.endpointCount.get(key)) != null ? _this$endpointCount$g : 0) + 1);
paths.forEach(path => {
var _instanceCount$get;
if (!this.entityCount.has(path.key)) {
this.entityCount.set(path.key, new Map());
}
const instanceCount = this.entityCount.get(path.key);
instanceCount.set(path.pk, ((_instanceCount$get = instanceCount.get(path.pk)) != null ? _instanceCount$get : 0) + 1);
});
// decrement
return () => {
if (key) {
const currentCount = this.endpointCount.get(key);
if (currentCount !== undefined) {
if (currentCount <= 1) {
this.endpointCount.delete(key);
// queue for cleanup
this.endpointsQ.add(key);
} else {
this.endpointCount.set(key, currentCount - 1);
}
}
}
paths.forEach(path => {
if (!this.entityCount.has(path.key)) {
return;
}
const instanceCount = this.entityCount.get(path.key);
const entityCount = instanceCount.get(path.pk);
if (entityCount !== undefined) {
if (entityCount <= 1) {
instanceCount.delete(path.pk);
// queue for cleanup
this.entitiesQ.push(path);
} else {
instanceCount.set(path.pk, entityCount - 1);
}
}
});
};
};
}
expiresAt({
fetchedAt,
expiresAt
}) {
return Math.max((expiresAt - fetchedAt) * this.options.expiryMultiplier, 120000) + fetchedAt;
}
runSweep() {
const state = this.controller.getState();
const entities = [];
const endpoints = [];
const now = Date.now();
const nextEndpointsQ = new Set();
for (const key of this.endpointsQ) {
var _state$meta$key;
if (!this.endpointCount.has(key) && this.expiresAt((_state$meta$key = state.meta[key]) != null ? _state$meta$key : {
fetchedAt: 0,
date: 0,
expiresAt: 0
}) < now) {
endpoints.push(key);
} else {
nextEndpointsQ.add(key);
}
}
this.endpointsQ = nextEndpointsQ;
const nextEntitiesQ = [];
for (const path of this.entitiesQ) {
var _this$entityCount$get, _state$entityMeta$pat, _state$entityMeta$pat2;
if (!((_this$entityCount$get = this.entityCount.get(path.key)) != null && _this$entityCount$get.has(path.pk)) && this.expiresAt((_state$entityMeta$pat = (_state$entityMeta$pat2 = state.entityMeta[path.key]) == null ? void 0 : _state$entityMeta$pat2[path.pk]) != null ? _state$entityMeta$pat : {
fetchedAt: 0,
date: 0,
expiresAt: 0
}) < now) {
entities.push(path);
} else {
nextEntitiesQ.push(path);
}
}
this.entitiesQ = nextEntitiesQ;
if (entities.length || endpoints.length) {
this.controller.dispatch({
type: GC,
entities,
endpoints
});
}
}
/** Calls the callback when client is not 'busy' with high priority interaction tasks
*
* Override for platform-specific implementations
*/
idleCallback(callback, options) {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(callback, options);
} else {
callback();
}
}
}
class ImmortalGCPolicy {
// eslint-disable-next-line @typescript-eslint/no-empty-function
init() {}
// eslint-disable-next-line @typescript-eslint/no-empty-function
cleanup() {}
createCountRef() {
return () => () => undefined;
}
}
function selectMeta(state, fetchKey) {
return state.meta[fetchKey];
}
const unsetDispatch = action => {
throw new Error(`Dispatching while constructing your middleware is not allowed. ` + `Other middleware would not be applied to this dispatch.`);
};
const unsetState = () => {
// 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
*/
class Controller {
/**
* Dispatches an action to Reactive Data Client reducer.
*
* @see https://dataclient.io/docs/api/Controller#dispatch
*/
/**
* 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
*/
/**
* Singleton to maintain referential equality between calls
*/
/**
* Handles garbage collection
*/
constructor({
dispatch = unsetDispatch,
getState = unsetState,
memo = new normalizr.MemoCache(),
gcPolicy = new ImmortalGCPolicy()
} = {}) {
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) {
/* istanbul ignore next */
this._dispatch = dispatch;
}
// TODO: drop when drop support for destructuring (0.14 and below)
get dispatch() {
return this._dispatch;
}
bindMiddleware({
dispatch,
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 = (endpoint, ...args) => {
const action = createFetch(endpoint, {
args
});
this.dispatch(action);
if (endpoint.schema) {
return action.meta.promise.then(input => normalizr.denormalize(endpoint.schema, input, {}, args));
}
return action.meta.promise;
};
/**
* Fetches only if endpoint is considered 'stale'; otherwise returns undefined
* @see https://dataclient.io/docs/api/Controller#fetchIfStale
*/
fetchIfStale = (endpoint, ...args) => {
const {
data,
expiresAt,
expiryStatus
} = this.getResponse(endpoint, ...args, this.getState());
if (expiryStatus !== normalizr.ExpiryStatus.Invalid && Date.now() <= expiresAt) return data;
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 = (endpoint, ...args) => args[0] !== null ? this.dispatch(createInvalidate(endpoint, {
args: args
})) : 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 => this.dispatch(createInvalidateAll(key => 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 => this.dispatch(createExpireAll(key => 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 = () => this.dispatch(createReset());
/**
* Sets value for the Queryable and args.
* @see https://dataclient.io/docs/api/Controller#set
*/
set(schema, ...rest) {
const value = rest[rest.length - 1];
const action = createSet(schema, {
args: rest.slice(0, rest.length - 1),
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 = (endpoint, ...rest) => {
const response = rest[rest.length - 1];
const action = createSetResponse(endpoint, {
args: rest.slice(0, rest.length - 1),
response
});
return this.dispatch(action);
};
/**
* Sets an error response for the Endpoint and args.
* @see https://dataclient.io/docs/api/Controller#setError
*/
setError = (endpoint, ...rest) => {
const response = rest[rest.length - 1];
const action = createSetResponse(endpoint, {
args: rest.slice(0, rest.length - 1),
response,
error: true
});
return this.dispatch(action);
};
/**
* Resolves an inflight fetch.
* @see https://dataclient.io/docs/api/Controller#resolve
*/
resolve = (endpoint, meta) => {
return this.dispatch(createSetResponse(endpoint, meta));
};
/**
* Marks a new subscription to a given Endpoint.
* @see https://dataclient.io/docs/api/Controller#subscribe
*/
subscribe = (endpoint, ...args) => args[0] !== null ? this.dispatch(createSubscription(endpoint, {
args: args
})) : Promise.resolve();
/**
* Marks completion of subscription to a given Endpoint.
* @see https://dataclient.io/docs/api/Controller#unsubscribe
*/
unsubscribe = (endpoint, ...args) => args[0] !== null ? this.dispatch(createUnsubscription(endpoint, {
args: args
})) : 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, fetchedAt) => {
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(endpoint, ...rest) {
if (rest[0] === null) return;
const state = rest[rest.length - 1];
// this is typescript generics breaking
const args = 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 == null ? void 0 : meta.errorPolicy) === 'soft') return;
return meta == null ? void 0 : meta.error;
}
/**
* Gets the (globally referentially stable) response for a given endpoint/args pair from state given.
* @see https://dataclient.io/docs/api/Controller#getResponse
*/
getResponse(endpoint, ...rest) {
// 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(endpoint, ...rest) {
const state = rest[rest.length - 1];
// this is typescript generics breaking
const args = 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 == null ? void 0 : 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, state.indexes, key) : cacheEndpoints;
if (!isActive) {
// when not active simply return the query input without denormalizing
return {
data: input,
expiryStatus: normalizr.ExpiryStatus.Valid,
expiresAt: Infinity,
countRef: () => () => undefined
};
}
let isInvalid = false;
if (shouldQuery) {
isInvalid = !normalizr.validateQueryKey(input);
// endpoint without entities
} else if (!schema || !schemaHasEntity(schema)) {
return {
data: cacheEndpoints,
expiryStatus: meta != null && meta.invalidated ? normalizr.ExpiryStatus.Invalid : cacheEndpoints && !endpoint.invalidIfStale ? normalizr.ExpiryStatus.Valid : normalizr.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);
// 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(schema, ...rest) {
const state = rest[rest.length - 1];
// this is typescript generics breaking
const args = rest.slice(0, rest.length - 1).map(ensurePojo);
return this.memo.query(schema, args, state.entities, state.indexes);
}
/**
* Queries the store for a Querable schema; providing related metadata
* @see https://dataclient.io/docs/api/Controller#getQueryMeta
*/
getQueryMeta(schema, ...rest) {
const state = rest[rest.length - 1];
// this is typescript generics breaking
const args = rest.slice(0, rest.length - 1).map(ensurePojo);
// 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, 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,
countRef: this.gcPolicy.createCountRef({
paths
})
};
}
getSchemaResponse(data, key, paths, entityMeta, expiresAt, invalidIfStale, meta = {}) {
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 != null && meta.invalidated || invalidDenormalize && !(meta != null && meta.error) ? normalizr.ExpiryStatus.Invalid : invalidDenormalize || invalidIfStale ? normalizr.ExpiryStatus.InvalidIfStale : normalizr.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, entityMeta) {
let expiresAt = Infinity;
for (const {
pk,
key
} of paths) {
var _entityMeta$key;
const entityExpiry = (_entityMeta$key = entityMeta[key]) == null || (_entityMeta$key = _entityMeta$key[pk]) == null ? void 0 : _entityMeta$key.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) {
if (normalizr.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 : schema;
if (typeof nestedSchema === 'function') {
return schemaHasEntity(nestedSchema);
}
return Object.values(nestedSchema).some(x => schemaHasEntity(x));
}
return false;
}
class Snapshot {
static abort = new AbortOptimistic();
state;
controller;
fetchedAt;
abort = Snapshot.abort;
constructor(controller, state, fetchedAt = 0) {
this.state = state;
this.controller = controller;
this.fetchedAt = fetchedAt;
}
/*************** Data Access ***************/
/** @see https://dataclient.io/docs/api/Snapshot#getResponse */
getResponse(endpoint, ...args) {
return this.controller.getResponse(endpoint, ...args, this.state);
}
/** @see https://dataclient.io/docs/api/Snapshot#getResponseMeta */
getResponseMeta(endpoint, ...args) {
return this.controller.getResponseMeta(endpoint, ...args, this.state);
}
/** @see https://dataclient.io/docs/api/Snapshot#getError */
getError(endpoint, ...args) {
return this.controller.getError(endpoint, ...args, this.state);
}
/**
* Retrieved memoized value for any Querable schema
* @see https://dataclient.io/docs/api/Snapshot#get
*/
get(schema, ...args) {
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(schema, ...args) {
return this.controller.getQueryMeta(schema, ...args, this.state);
}
}
class ResetError extends Error {
name = 'ResetError';
constructor() {
super('Aborted due to RESET');
}
}
/** Handles all async network dispatches
*
* Dedupes concurrent requests by keeping track of all fetches in flight
* and returning existing promises for requests already in flight.
*
* Interfaces with store via a redux-compatible middleware.
*
* @see https://dataclient.io/docs/api/NetworkManager
*/
class NetworkManager {
fetched = Object.create(null);
resolvers = {};
rejectors = {};
fetchedAt = {};
controller = new Controller();
constructor({
dataExpiryLength = 60000,
errorExpiryLength = 1000
} = {}) {
this.dataExpiryLength = dataExpiryLength;
this.errorExpiryLength = errorExpiryLength;
}
middleware = controller => {
this.controller = controller;
return next => action => {
switch (action.type) {
case FETCH:
this.handleFetch(action);
// This is the only case that causes any state change
// It's important to intercept other fetches as we don't want to trigger reducers during
// render - so we need to stop 'readonly' fetches which can be triggered in render
if (action.endpoint.getOptimisticResponse !== undefined && action.endpoint.sideEffect) {
return next(action);
}
return Promise.resolve();
case SET_RESPONSE:
// only set after new state is computed
return next(action).then(() => {
if (action.key in this.fetched) {
var _controller$getState$;
// Note: meta *must* be set by reducer so this should be safe
const error = (_controller$getState$ = controller.getState().meta[action.key]) == null ? void 0 : _controller$getState$.error;
// processing errors result in state meta having error, so we should reject the promise
if (error) {
this.handleSet(createSetResponse(action.endpoint, {
args: action.args,
response: error,
fetchedAt: action.meta.fetchedAt,
error: true
}));
} else {
this.handleSet(action);
}
}
});
case RESET:
{
const rejectors = {
...this.rejectors
};
this.clearAll();
return next(action).then(() => {
// there could be external listeners to the promise
// this must happen after commit so our own rejector knows not to dispatch an error based on this
for (const k in rejectors) {
rejectors[k](new ResetError());
}
});
}
default:
return next(action);
}
};
};
/** On mount */
init() {
delete this.cleanupDate;
}
/** Ensures all promises are completed by rejecting remaining. */
cleanup() {
// ensure no dispatches after unmount
// this must be reversible (done in init) so useEffect() remains symmetric
this.cleanupDate = Date.now();
}
/** Used by DevtoolsManager to determine whether to log an action */
skipLogging(action) {
/* istanbul ignore next */
return action.type === FETCH && action.key in this.fetched;
}
allSettled() {
const fetches = Object.values(this.fetched);
if (fetches.length) return Promise.allSettled(fetches);
}
/** Clear all promise state */
clearAll() {
for (const k in this.rejectors) {
this.clear(k);
}
}
/** Clear promise state for a given key */
clear(key) {
this.fetched[key].catch(() => {});
delete this.resolvers[key];
delete this.rejectors[key];
delete this.fetched[key];
delete this.fetchedAt[key];
}
getLastReset() {
if (this.cleanupDate) return this.cleanupDate;
return this.controller.getState().lastReset;
}
/** Called when middleware intercepts 'rdc/fetch' action.
*
* Will then start a promise for a key and potentially start the network
* fetch.
*
* Uses throttle endpoints without sideEffects. This is valuable
* for ensures mutation requests always go through.
*/
handleFetch(action) {
const {
resolve,
reject,
fetchedAt
} = action.meta;
const throttle = !action.endpoint.sideEffect;
const deferedFetch = () => {
let promise = action.endpoint(...action.args);
const resolvePromise = promise => promise.then(data => {
resolve(data);
return data;
}).catch(error => {
reject(error);
throw error;
});
// schedule non-throttled resolutions in a microtask before set
// this enables users awaiting their fetch to trigger any react updates needed to deal
// with upcoming changes because of the fetch (for instance avoiding suspense if something is deleted)
if (!throttle) {
promise = resolvePromise(promise);
}
promise = promise.then(response => {
let lastReset = this.getLastReset();
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production' && isNaN(lastReset)) {
console.error('state.lastReset is NaN. Only positive timestamps are valid.');
lastReset = 0;
}
// don't update state with promises started before last clear
if (fetchedAt >= lastReset) {
this.controller.resolve(action.endpoint, {
args: action.args,
response,
fetchedAt
});
}
return response;
}).catch(error => {
const lastReset = this.getLastReset();
// don't update state with promises started before last clear
if (fetchedAt >= lastReset) {
this.controller.resolve(action.endpoint, {
args: action.args,
response: error,
fetchedAt,
error: true
});
}
throw error;
});
return promise;
};
if (throttle) {
return this.throttle(action.key, deferedFetch, fetchedAt).then(data => resolve(data)).catch(error => reject(error));
} else {
return deferedFetch().catch(() => {});
}
}
/** Called when middleware intercepts a set action.
*
* Will resolve the promise associated with set key.
*/
handleSet(action) {
// this can still turn out to be untrue since this is async
if (action.key in this.fetched) {
let promiseHandler;
if (action.error) {
promiseHandler = this.rejectors[action.key];
} else {
promiseHandler = this.resolvers[action.key];
}
promiseHandler(action.response);
// since we're resolved we no longer need to keep track of this promise
this.clear(action.key);
}
}
/** Ensures only one request for a given key is in flight at any time
*
* Uses key to either retrieve in-flight promise, or if not
* create a new promise and call fetch.
*
* Note: The new promise is not actually tied to fetch at all,
* but is resolved when the expected 'recieve' action is processed.
* This ensures promises are resolved only once their data is processed
* by the reducer.
*/
throttle(key, fetch, fetchedAt) {
const lastReset = this.getLastReset();
// we're already fetching so reuse the promise
// fetches after reset do not count
if (key in this.fetched && this.fetchedAt[key] > lastReset) {
return this.fetched[key];
}
this.fetched[key] = new Promise((resolve, reject) => {
this.resolvers[key] = resolve;
this.rejectors[key] = reject;
});
this.fetchedAt[key] = fetchedAt;
this.idleCallback(() => {
// since our real promise is resolved via the wrapReducer(),
// we should just stop all errors here.
// TODO: decouple this from useFetcher() (that's what's dispatching the error the resolves in here)
fetch().catch(() => null);
}, {
timeout: 500
});
return this.fetched[key];
}
/** Calls the callback when client is not 'busy' with high priority interaction tasks
*
* Override for platform-specific implementations
*/
idleCallback(callback, options) {
callback();
}
}
function applyManager(managers, controller) {
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'production' && !managers.find(mgr => mgr instanceof NetworkManager)) {
console.warn('NetworkManager not found; this is a required manager.');
console.warn('See https://dataclient.io/docs/guides/redux for hooking up redux');
}
return managers.map((manager, i) => {
if (!manager.middleware) manager.middleware = manager.getMiddleware == null ? void 0 : manager.getMiddleware();
return api => {
if (i === 0) {
controller.bindMiddleware(api);
}
// controller is a superset of the middleware API
return manager.middleware(controller);
};
});
}
/* These should be compatible with redux */
/* The next are types from React; but we don't want dependencies on it */
function initManager(managers, controller, initialState) {
return () => {
managers.forEach(manager => {
manager.init == null || manager.init(initialState);
});
controller.gcPolicy.init(controller);
return () => {
managers.forEach(manager => {
manager.cleanup();
});
controller.gcPolicy.cleanup();
};
};
}
class BrowserConnectionListener {
isOnline() {
if (navigator.onLine !== undefined) {
return navigator.onLine;
}
return true;
}
addOnlineListener(handler) {
addEventListener('online', handler);
}
removeOnlineListener(handler) {
removeEventListener('online', handler);
}
addOfflineListener(handler) {
addEventListener('offline', handler);
}
removeOfflineListener(handler) {
removeEventListener('offline', handler);
}
}
class AlwaysOnlineConnectionListener {
isOnline() {
/* istanbul ignore next */
return true;
}
addOnlineListener() {}
removeOnlineListener() {}
addOfflineListener() {}
removeOfflineListener() {}
}
let DefaultConnectionListener;
/* istanbul ignore if */
if (typeof navigator !== 'undefined' && typeof addEventListener === 'function') {
DefaultConnectionListener = BrowserConnectionListener;
} else {
/* istanbul ignore next */
DefaultConnectionListener = AlwaysOnlineConnectionListener;
}
var DefaultConnectionListener$1 = DefaultConnectionListener;
/**
* PollingSubscription keeps a given resource updated by
* dispatching a fetch at a rate equal to the minimum update
* interval requested.
*
* @see https://dataclient.io/docs/api/PollingSubscription
*/
class PollingSubscription {
frequencyHistogram = new Map();
constructor(action, controller, connectionListener) {
if (action.endpoint.pollFrequency === undefined) throw new Error('frequency needed for polling subscription');
this.endpoint = action.endpoint;
this.frequency = action.endpoint.pollFrequency;
this.args = action.args;
this.key = action.key;
this.frequencyHistogram.set(this.frequency, 1);
this.controller = controller;
this.connectionListener = connectionListener || new DefaultConnectionListener$1();
// Kickstart running since this is initialized after the online notif is sent
if (this.connectionListener.isOnline()) {
this.onlineListener();
} else {
this.offlineListener();
}
}
/** Subscribe to a frequency */
add(frequency) {
if (frequency === undefined) return;
if (this.frequencyHistogram.has(frequency)) {
this.frequencyHistogram.set(frequency, this.frequencyHistogram.get(frequency) + 1);
} else {
this.frequencyHistogram.set(frequency, 1);
// new min so restart service
if (frequency < this.frequency) {
this.frequency = frequency;
this.run();
}
}
}
/** Unsubscribe from a frequency */
remove(frequency) {
if (frequency === undefined) return false;
if (this.frequencyHistogram.has(frequency)) {
this.frequencyHistogram.set(frequency, this.frequencyHistogram.get(frequency) - 1);
if (this.frequencyHistogram.get(frequency) < 1) {
this.frequencyHistogram.delete(frequency);
// nothing subscribed to this anymore...it is invalid
if (this.frequencyHistogram.size === 0) {
this.cleanup();
return true;
}
// this was the min, so find the next size
if (frequency <= this.frequency) {
this.frequency = Math.min(...this.frequencyHistogram.keys());
this.run();
}
}
} /* istanbul ignore next */else if (process.env.NODE_ENV !== 'production') {
console.error(`Mismatched remove: ${frequency} is not subscribed for ${this.key}`);
}
return false;
}
/** Cleanup means clearing out background interval. */
cleanup() {
if (this.intervalId) {
clearInterval(this.intervalId);
delete this.intervalId;
}
if (this.lastIntervalId) {
clearInterval(this.lastIntervalId);
delete this.lastIntervalId;
}
if (this.startId) {
clearTimeout(this.startId);
delete this.startId;
}
this.connectionListener.removeOnlineListener(this.onlineListener);
this.connectionListener.removeOfflineListener(this.offlineListener);
}
/** Trigger request for latest resource */
update() {
const sup = this.endpoint;
const endpoint = function (...args) {
return sup.call(this, ...args);
};
Object.assign(endpoint, this.endpoint);
endpoint.dataExpiryLength = this.frequency / 2;
endpoint.errorExpiryLength = this.frequency / 10;
endpoint.errorPolicy = () => 'soft';
endpoint.key = () => this.key;
// stop any errors here from bubbling
this.controller.fetch(endpoint, ...this.args).catch(() => null);
}
/** What happens when browser goes offline */
offlineListener = () => {
// this clears existing listeners, so no need to clear offline listener
this.cleanup();
this.connectionListener.addOnlineListener(this.onlineListener);
};
/** What happens when browser comes online */
onlineListener = () => {
this.connectionListener.removeOnlineListener(this.onlineListener);
const now = Date.now();
this.startId = setTimeout(() => {
if (this.startId) {
delete this.startId;
this.update();
this.run();
} else if (process.env.NODE_ENV !== 'production') {
console.warn(`Poll setTimeout for ${this.key} still running, but timeoutId deleted`);
}
}, Math.max(0, this.lastFetchTime() - now + this.frequency));
this.connectionListener.addOfflineListener(this.offlineListener);
};
/** Run polling process with current frequency
*
* Will clean up old poll interval on next run
*/
run() {
if (this.startId) return;
if (this.intervalId) this.lastIntervalId = this.intervalId;
this.intervalId = setInterval(() => {
// since we don't know how long into the last poll it was before resetting
// we wait til the next fetch to clear old intervals
if (this.lastIntervalId) {
clearInterval(this.lastIntervalId);
delete this.lastIntervalId;
}
if (this.intervalId) this.update();else if (process.env.NODE_ENV !== 'production') {
console.warn(`Poll intervalId for ${this.key} still running, but intervalId deleted`);
}
}, this.frequency);
}
/** Last fetch time */
lastFetchTime() {
var _this$controller$getS, _this$controller$getS2;
return (_this$controller$getS = (_this$controller$getS2 = this.controller.getState().meta[this.key]) == null ? void 0 : _this$controller$getS2.date) != null ? _this$controller$getS : 0;
}
}
/** Interface handling a single resource subscription */
/** The static class that constructs Subscription */
/** Handles subscription actions -> fetch or set actions
*
* Constructor takes a SubscriptionConstructable class to control how
* subscriptions are handled. (e.g., polling, websockets)
*
* @see https://dataclient.io/docs/api/SubscriptionManager
*/
class SubscriptionManager {
subscriptions = {};
controller = new Controller();
constructor(Subscription) {
this.Subscription = Subscription;
}
middleware = controller => {
this.controller = controller;
return next => action => {
switch (action.type) {
case SUBSCRIBE:
try {
this.handleSubscribe(action);
} catch (e) {
console.error(e);
}
return Promise.resolve();
case UNSUBSCRIBE:
this.handleUnsubscribe(action);
return Promise.resolve();
default:
return next(action);
}
};
};
/** Ensures all subscriptions are cleaned up. */
cleanup() {
for (const key in this.subscriptions) {
this.subscriptions[key].cleanup();
}
}
/** Called when middleware intercepts 'rdc/subscribe' action.
*
*/
handleSubscribe(action) {
const key = action.key;
if (key in this.subscriptions) {
const frequency = action.endpoint.pollFrequency;
this.subscriptions[key].add(frequency);
} else {
this.subscriptions[key] = new this.Subscription(action, this.controller);
}
}
/** Called when middleware intercepts 'rdc/unsubscribe' action.
*
*/
handleUnsubscribe(action) {
const key = action.key;
/* istanbul ignore else */
if (key in this.subscriptions) {
const frequency = action.endpoint.pollFrequency;
const empty = this.subscriptions[key].remove(frequency);
if (empty) {
delete this.subscriptions[key];
}
} else if (process.env.NODE_ENV !== 'production') {
console.error(`Mismatched unsubscribe: ${key} is not subscribed`);
}
}
}
var _DevToolsManager;
let DEFAULT_CONFIG = {};
if (process.env.NODE_ENV !== 'production') {
var _globalThis$document;
const extraEndpointKeys = ['dataExpiryLength', 'errorExpiryLength', 'errorPolicy', 'invalidIfStale', 'pollFrequency', 'getOptimisticResponse', 'update'];
function seri