UNPKG

@data-client/core

Version:

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

1,652 lines (1,540 loc) 57.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 }); 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 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: args.map(ensurePojo), 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, entitiesMeta } = normalizr.normalize(action.schema, value, action.args, state, action.meta); return { entities, endpoints: state.endpoints, indexes, meta: state.meta, entitiesMeta, 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, entitiesMeta } = 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(`Endpoint.update() error: ${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 } }, entitiesMeta, 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 aborts useSuspense() 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, _entitiesMeta$key; (_entities$key = state.entities[key]) == null || delete _entities$key[pk]; (_entitiesMeta$key = state.entitiesMeta[key]) == null || delete _entitiesMeta$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: {}, entitiesMeta: {}, 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 }; } 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); if (typeof this.intervalId === 'object' && 'unref' in this.intervalId) { this.intervalId.unref(); } } 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; const { key, pk } = path; if (!this.entityCount.has(key)) { this.entityCount.set(key, new Map()); } const instanceCount = this.entityCount.get(key); instanceCount.set(pk, ((_instanceCount$get = instanceCount.get(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 => { const { key, pk } = path; if (!this.entityCount.has(key)) { return; } const instanceCount = this.entityCount.get(key); const entityCount = instanceCount.get(pk); if (entityCount !== undefined) { if (entityCount <= 1) { instanceCount.delete(pk); // queue for cleanup this.entitiesQ.push(path); } else { instanceCount.set(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$entitiesMeta$p, _state$entitiesMeta$p2; if (!((_this$entityCount$get = this.entityCount.get(path.key)) != null && _this$entityCount$get.has(path.pk)) && this.expiresAt((_state$entitiesMeta$p = (_state$entitiesMeta$p2 = state.entitiesMeta[path.key]) == null ? void 0 : _state$entitiesMeta$p2[path.pk]) != null ? _state$entitiesMeta$p : { 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.getResponseMeta(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, args] = extractStateAndArgs(rest); 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, 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 || !requiresDenormalize(schema)) { return { data: cacheEndpoints, expiryStatus: this.getExpiryStatus(cacheEndpoints === undefined, !!endpoint.invalidIfStale, meta), expiresAt: expiresAt || 0, countRef: this.gcPolicy.createCountRef({ key }) }; } const { data, paths } = this.memo.denormalize(schema, input, state.entities, args); if (!expiresAt) { // note: isInvalid can only be true if shouldQuery is true if (isInvalid) expiresAt = 1; // fallback to entity expiry time else expiresAt = entityExpiresAt(paths, state.entitiesMeta); } return { data, expiryStatus: this.getExpiryStatus(typeof data === 'symbol', !!endpoint.invalidIfStale || isInvalid, meta), expiresAt, countRef: this.gcPolicy.createCountRef({ key, paths }) }; } /** * Queries the store for a Querable schema * @see https://dataclient.io/docs/api/Controller#get */ get(schema, ...rest) { const [state, args] = extractStateAndArgs(rest); const { data } = this.memo.query(schema, args, state); return typeof data === 'symbol' ? undefined : data; } /** * Queries the store for a Querable schema; providing related metadata * @see https://dataclient.io/docs/api/Controller#getQueryMeta */ getQueryMeta(schema, ...rest) { const [state, args] = extractStateAndArgs(rest); const { data, paths } = this.memo.query(schema, args, state); return { data: typeof data === 'symbol' ? undefined : data, countRef: this.gcPolicy.createCountRef({ paths }) }; } getExpiryStatus(invalidData, invalidIfStale, meta = {}) { // 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 return meta.invalidated || invalidData && !meta.error ? normalizr.ExpiryStatus.Invalid : invalidData || invalidIfStale ? normalizr.ExpiryStatus.InvalidIfStale : normalizr.ExpiryStatus.Valid; } } // benchmark: https://www.measurethat.net/Benchmarks/Show/24691/0/min-reducer-vs-imperative-with-paths // earliest expiry dictates age function entityExpiresAt(paths, entitiesMeta) { let expiresAt = Infinity; for (const { key, pk } of paths) { var _entitiesMeta$key; const entityExpiry = (_entitiesMeta$key = entitiesMeta[key]) == null || (_entitiesMeta$key = _entitiesMeta$key[pk]) == null ? void 0 : _entitiesMeta$key.expiresAt; // expiresAt will always resolve to false with any comparison if (entityExpiry < expiresAt) expiresAt = entityExpiry; } return expiresAt; } /** Whether `denormalize()` must run to reconstruct the response. * * True iff some node in the schema tree defines `normalize` — meaning it * transforms the response when written (entity write, polymorphic hoist, * lazy delegation, etc.), so the cached value differs from the response. * Mirrors the dispatch in `getVisit.ts`: schema classes are detected here * by the same hook the visit walker uses, so we never need to walk their * instance fields. */ function requiresDenormalize(schema) { if (!schema) return false; if (Array.isArray(schema)) return schema.length !== 0 && requiresDenormalize(schema[0]); // Must reject primitives before probing `.normalize` — `String.prototype.normalize` exists. const t = typeof schema; if (t !== 'object' && t !== 'function') return false; if (typeof schema.normalize === 'function') return true; // Plain-object schema map (e.g. `{ data: [Tacos], page: { ... } }`). return Object.values(schema).some(x => requiresDenormalize(x)); } 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); } } /** Extract state and args from rest params, applying ensurePojo to args */ function extractStateAndArgs(rest) { const l = rest.length; const args = new Array(l - 1); for (let i = 0; i < l - 1; i++) { // handle FormData args[i] = ensurePojo(rest[i]); } // this is typescript generics breaking return [rest[l - 1], args]; } 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 { fetching = new Map(); 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 (this.fetching.has(action.key)) { 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: { // take snapshot of rejectors at this point in time // we must use Array.from since iteration does not freeze state at this point in time const fetches = Array.from(this.fetching.values()); 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 { reject } of fetches) { reject(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 && this.fetching.has(action.key); } allSettled() { if (this.fetching.size) return Promise.allSettled(this.fetching.values().map(({ promise }) => promise)); } /** Clear all promise state */ clearAll() { for (const k of this.fetching.keys()) { this.clear(k); } } /** Clear promise state for a given key */ clear(key) { if (this.fetching.has(key)) { this.fetching.get(key).promise.catch(() => {}); this.fetching.delete(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 (this.fetching.has(action.key)) { const { reject, resolve } = this.fetching.get(action.key); if (action.error) { reject(action.response); } else { resolve(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 'receive' 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(); let fetchMeta = this.fetching.get(key); // we're already fetching so reuse the promise // fetches after reset do not count if (fetchMeta && fetchMeta.fetchedAt > lastReset) { return fetchMeta.promise; } fetchMeta = newFetchMeta(fetchedAt); this.fetching.set(key, fetchMeta); 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 fetchMeta.promise; } /** Calls the callback when client is not 'busy' with high priority interaction tasks * * Override for platform-specific implementations */ idleCallback(callback, options) { callback(); } } function newFetchMeta(fetchedAt) { const fetchMeta = { fetchedAt }; fetchMeta.promise = new Promise((resolve, reject) => { fetchMeta.resolve = resolve; fetchMeta.reject = reject; }); return fetchMeta; } 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 ex