UNPKG

@data-client/core

Version:

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

1,640 lines (1,524 loc) 56.8 kB
'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