Direct copy of source code, need to remove internal dependencies

This commit is contained in:
Like Zhu 2021-06-07 14:36:52 -07:00
Родитель 022bfb9d53
Коммит 3240c36f7b
55 изменённых файлов: 2670 добавлений и 0 удалений

2
.gitignore поставляемый
Просмотреть файл

@ -344,3 +344,5 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
*.orig

Просмотреть файл

@ -0,0 +1,24 @@
# @bingads-webui/overreact
**@bingads-webui/overreact** is a new component.
## References
[API reference and tutorials](http://bingadsinternal.azurewebsites.net/campaignui/client-data/jsdoc/overreact)
## Installing
When using yarn, run the following command:
```shell
yarn add --save @bingads-webui/overreact
```
## Using
After installing, import the package into your code and start using it
```javascript
import { Overreact } from '@bingads-webui/overreact';
/// ...
```

Просмотреть файл

@ -0,0 +1,13 @@
export * from './src/data-fetcher';
export * from './src/environment';
export * from './src/spec';
export * from './src/store';
export * from './src/schema';
export * from './src/hooks';
export {
FetchPolicy,
middlewareTypes,
createFetchPolicyMiddleware,
createErrorMiddleware,
createInstrumentationMiddleware,
} from './src/middleware';

Просмотреть файл

@ -0,0 +1,31 @@
{
"name": "@microsoft/overreact-core",
"description": "TO BE ADDED",
"version": "0.1.0",
"main": "dist/index",
"license": "MIT",
"dependencies": {
"@bingads-webui-universal/observer-pattern": "*",
"@bingads-webui-universal/primitive-utilities": "^1.4.5",
"bluebird": "3.5.0",
"json-stable-stringify": "*",
"prop-types": ">=15.6.0",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"regenerator-runtime": "^0.13.2",
"underscore": "^1.10.2",
"uuid": "^3.3.3"
},
"devDependencies": {
"@bingads-webui/mca-odata-schemas": ">=1.0.0-alpha.2019091919",
"@bingads-webui/edm-core": ">=1.0.0",
"@bingads-webui/edm-odata": "^1.1.6",
"@bingads-webui/edm-resource-identifiers": ">=1.0.0",
"@bingads-webui/http-util": "^1.0.0",
"@bingads-webui/reflection": ">=1.0.0",
"fetch-mock": "^9.10.1",
"jquery": "2.2.4",
"json-stable-stringify": "*",
"query-string": "*"
}
}

Просмотреть файл

@ -0,0 +1,117 @@
import _ from 'underscore';
import React, { useRef, memo } from 'react';
import PropTypes from 'prop-types';
import { EnvironmentContext } from '../environment/context';
const MAX_REQUEST_BATCH_SIZE = 10;
const FETCH_INTERVAL = 50;
const INIT_MIDDLEWARE_STATES = { isResponseFromStore: false };
function executeRequests(environment, requests) {
const pendingRequests = [
...requests,
];
while (pendingRequests.length > 0) {
const req = pendingRequests.shift();
const {
requestContract,
spec,
variables,
data,
id,
} = req;
req.middlewareStates = {
...INIT_MIDDLEWARE_STATES,
};
const { responseContract } = spec;
const {
verb, uriFactoryFn, headerFactoryFn, payloadFactoryFn,
} = requestContract;
const uri = uriFactoryFn({ requestContract, variables, data });
const header = headerFactoryFn && headerFactoryFn({ requestContract, variables, data });
const payload = payloadFactoryFn && payloadFactoryFn({ requestContract, variables, data });
const requestor = environment.getRequestor(id, spec, variables, req.middlewareStates);
requestor(uri, verb, header, payload).execute({
onComplete: (response) => {
responseContract.onGetResponse(environment, response, req);
},
onError: (error) => {
responseContract.onGetError(environment, req, error);
},
});
}
}
function batchRequests(environment, batchSize) {
// console.log(`executing requests for batch size ${batchSize}`);
// we'll try to batch requests
const requests = [];
while (requests.length < batchSize) {
const request = environment.removeRequest();
requests.push(request);
}
executeRequests(environment, requests);
}
export function useEnvironmentInitialization(environment) {
// const [currentEnvironment, setCurrEnv] = useState(environment);
const timer = useRef(null);
// TODO: we need better way to subscribe here
// current way will result in memory leak or other bugs when environment change
environment.subscribe(() => {
if (!timer.current) {
clearTimeout(timer.current);
}
if (environment.requestCount() >= MAX_REQUEST_BATCH_SIZE) {
// immediately execute requests to reduce queue size
batchRequests(environment, MAX_REQUEST_BATCH_SIZE);
} else {
// a few requests outstanding, wait a bit to accumulate more.
timer.current = setTimeout(() => {
batchRequests(environment, environment.requestCount());
}, FETCH_INTERVAL);
}
});
}
// default top-level data fetcher
export const DataFetcher = memo((props) => {
const {
environment,
environments,
children,
} = props;
useEnvironmentInitialization(environment);
_.each(environments, env => useEnvironmentInitialization(env));
return (
<EnvironmentContext.Provider value={[environment, environments]}>
{children}
</EnvironmentContext.Provider>
);
});
DataFetcher.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
environment: PropTypes.object.isRequired,
environments: PropTypes.arrayOf(PropTypes.object),
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
DataFetcher.defaultProps = {
environments: null,
};

Просмотреть файл

@ -0,0 +1,3 @@
import React from 'react';
export const EnvironmentContext = React.createContext();

Просмотреть файл

@ -0,0 +1,107 @@
import 'regenerator-runtime/runtime';
import { requestWithMiddleware, WrappedRequestor } from '../middleware';
export class Environment {
constructor(networkRequestor, schema, store, middlewares, tag) {
this.networkRequestor = networkRequestor;
this.schema = schema;
// an FIFO queue
this.requestQueue = [];
this.dataFetcherSubscriber = [];
// initialize the store
this.store = store;
this.middlewares = middlewares || {};
this.tag = tag;
this.notifyObservers = this.notifyObservers.bind(this);
this.pushRequest = this.pushRequest.bind(this);
this.removeRequest = this.removeRequest.bind(this);
this.requestCount = this.requestCount.bind(this);
this.subscribe = this.subscribe.bind(this);
this.unsubscribe = this.unsubscribe.bind(this);
this.getSchema = this.getSchema.bind(this);
this.getRequestor = this.getRequestor.bind(this);
this.dataRefIdPool = {};
}
getRequestor(id, spec, variables, middlewareStates) {
return (uri, verb, header, payload) => ({
execute: (sink) => {
const wrappedRequestor = new WrappedRequestor({
requestor: this.networkRequestor,
uri,
verb,
header,
payload,
spec,
variables,
store: this.store,
dataRefId: id,
middlewareStates,
});
const res = requestWithMiddleware(wrappedRequestor, this.middlewares);
res
.then((value) => { sink.onComplete(value); })
.catch((err) => { sink.onError(err); });
},
});
}
getSchema() {
return this.schema;
}
notifyObservers() {
for (let i = 0; i < this.dataFetcherSubscriber.length; i += 1) {
this.dataFetcherSubscriber[i].notify(this.requestQueue);
}
}
/*
The request will have the following shape:
{
id,
requestContract,
spec,
variables,
data,
dataCb,
}
*/
pushRequest(request) {
this.requestQueue.push(request);
this.notifyObservers();
}
removeRequest() {
return this.requestQueue.shift();
}
requestCount() {
return this.requestQueue.length;
}
subscribe(notifyCb) {
this.dataFetcherSubscriber.push({
notify: notifyCb,
});
return this.dataFetcherSubscriber.length - 1;
}
unsubscribe(id) {
return this.dataFetcherSubscriber.splice(id, 1).length;
}
}

Просмотреть файл

@ -0,0 +1,4 @@
export * from './environment';
export * from './context';
export * from './use-environment';

Просмотреть файл

@ -0,0 +1,14 @@
import _ from 'underscore';
import { useContext } from 'react';
import { EnvironmentContext } from './context';
export function useEnvironment(environmentLookupFn) {
const [environment, environments] = useContext(EnvironmentContext);
if (_.isFunction(environmentLookupFn)) {
const specificEnv = _.find(environments, env => env && env.tag && environmentLookupFn(env.tag));
if (!_.isEmpty(specificEnv)) {
return specificEnv;
}
}
return environment || _.first(environments);
}

Просмотреть файл

@ -0,0 +1,31 @@
import _ from 'underscore';
import { responseTypes } from '../spec';
import { FetchPolicy } from '../middleware';
import { OVERREACT_ID_FIELD_NAME } from '../store/consts';
export function getDataFromRecords(records, responseContract) {
const values = _.map(records, record => _.omit(record.getData(), OVERREACT_ID_FIELD_NAME));
return responseContract.responseType === responseTypes.COLL ? values : values[0];
}
function getDefaultLookupCacheFn(varKeySelector, dataKeySelector) {
return (items, variables) =>
_.find(items, item => dataKeySelector(item) === varKeySelector(variables));
}
export function getLookupCacheFn(lookupCacheFn, spec, fetchPolicy) {
const { requestContract, responseContract } = spec;
if (fetchPolicy === FetchPolicy.StoreOrNetwork) {
if (lookupCacheFn) {
return lookupCacheFn;
}
if (_.isFunction(requestContract.keySelector)
&& _.isFunction(responseContract.keySelector)
&& responseContract.responseType === responseTypes.ENTITY) {
return getDefaultLookupCacheFn(requestContract.keySelector, responseContract.keySelector);
}
}
return null;
}

Просмотреть файл

@ -0,0 +1,6 @@
export * from './use-fetch';
export * from './use-mutation';
export { useRefetch } from './use-refetch';
export { usePagination } from './use-pagination';
export * from './use-data-ref-id';
export { usePromisify } from './use-promisify';

Просмотреть файл

@ -0,0 +1,23 @@
import _ from 'underscore';
import { getRecordGroup } from '../store';
export function getCacheIds({
store,
requestContract,
variables,
lookupFn,
}) {
const recordGroup = getRecordGroup(store, requestContract);
const { records } = recordGroup;
const cacheRawItems = _.map(records, record => record.data);
const cacheHit = lookupFn(cacheRawItems, variables);
const overreactIds = _.chain(_.flatten([cacheHit], true))
.map((data) => {
const record = _.find(records, r => r.data === data);
return record ? record.id : undefined;
})
.compact()
.value();
return overreactIds;
}

Просмотреть файл

@ -0,0 +1,19 @@
import { isFunction } from 'underscore';
export function getMergedConfig(config, origin) {
// create new instance of origin, origin is also required to be an object
const newInstance = { ...origin };
if (!config) {
return newInstance;
}
if (isFunction(config)) {
return config(newInstance);
}
return {
...origin,
...config,
};
}

Просмотреть файл

@ -0,0 +1,9 @@
export class OverreactRequest {
constructor(options) {
Object.assign(this, options);
if (!this.id || !this.requestContract) {
throw new Error('id and requestContract are required fields to build request object');
}
}
}

Просмотреть файл

@ -0,0 +1,46 @@
// this hook is used to create/retrieve a dataRefId, which
// will then be used/shared amongst useFetch/useMutation/usePagination/etc hooks.
// In a React component settings, this will act as a "component ID", where
// all the custom hooks share a same dataRefId, so they can work on the same
// set of data records.
// For example, a "usePagination" hook will retrieve a set of records, and store
// the data IDs in a certain dataRefId "A". Consequent "useMutation" call
// bound to the same dataRefId will have immediate access to the current data from pagination.
// Next time the mutation call tries to CREATE a new entity, it will now where
// to append the new entity.
// The hook adds/retrieves dataRefId from a pool in context. An optional "name"
// can be given so a compnent can restore previous data state even after unmounting.
// A direct example will be when a Paginated timeline component was unmounted/remounted,
// previously loaded feeds and scroll position can be reinstiated.
import { useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useEnvironment } from '../environment';
function generateNewDataRefId() {
return uuidv4();
}
export function useDataRefId(name, environmentLookupFn) {
const environment = useEnvironment(environmentLookupFn);
const dataRefIdRef = useRef(null);
if (!name && dataRefIdRef.current) {
return dataRefIdRef.current;
}
if (environment) {
if (name) {
// when using named dataRefId, we'll want to preserve it.
if (!environment.dataRefIdPool[name]) {
environment.dataRefIdPool[name] = generateNewDataRefId();
}
dataRefIdRef.current = environment.dataRefIdPool[name];
} else {
dataRefIdRef.current = generateNewDataRefId();
}
}
return dataRefIdRef.current;
}

Просмотреть файл

@ -0,0 +1,20 @@
import { useRef, useEffect, useDebugValue } from 'react';
import { isEqual } from 'underscore';
export function useDeepEqualEffect(fn, deps) {
const isFirst = useRef(true);
const prevDeps = useRef(deps);
useEffect(() => {
const isSame = prevDeps.current.every((obj, key) => isEqual(obj, deps[key]));
if (isFirst.current || !isSame) {
fn();
}
isFirst.current = false;
prevDeps.current = deps;
}, [deps, fn]);
useDebugValue('useDeepEqualEffect');
}

Просмотреть файл

@ -0,0 +1,142 @@
import _ from 'underscore';
import { useRef, useReducer, useCallback, useMemo, useEffect } from 'react';
import { useEnvironment } from '../environment';
import { useDeepEqualEffect } from './use-deep-equal-effect';
import { getDataNode, createDataNode, getRecords, getDataRef, getRecordsById, updateDataRefWithIds } from '../store';
import { OverreactRequest } from './overreact-request';
import { getCacheIds } from './lookup-cache';
import { getDataFromRecords, getLookupCacheFn } from './helper';
export function equalityFn(newData, oldData) {
return _.isEqual(newData, oldData);
}
export function useFetch(dataRefId, spec, variables, config) {
const {
requestContract,
responseContract,
environmentLookupFn,
} = spec;
const requestRequired = useRef(true);
const currentData = useRef(undefined);
const currentError = useRef(undefined);
const [, forceRender] = useReducer(x => x + 1, 0);
const environment = useEnvironment(environmentLookupFn);
const { postponeRead, lookupCacheByVariables } = config || {};
const lookupFn = useMemo(
() => getLookupCacheFn(lookupCacheByVariables, spec, requestContract.fetchPolicy),
[lookupCacheByVariables, requestContract.fetchPolicy, spec]
);
// TODO: dataCallback shall take a param, which is a set of
// overreact IDs, to efficiently compare data and force render
const dataCallback = useCallback(() => {
// fetch data from store
if (environment) {
const { store } = environment;
const records = getRecords(store, requestContract, dataRefId);
if (records) {
const data = getDataFromRecords(records, responseContract);
if (equalityFn(data, currentData.current)) {
return;
}
currentData.current = data;
forceRender();
}
}
}, [dataRefId, environment, requestContract, responseContract]);
const errorCallback = useCallback(() => {
if (environment) {
const { store } = environment;
const dataRef = getDataRef(store, requestContract, dataRefId);
const {
status: {
error,
} = {},
} = dataRef;
if (equalityFn(error, currentError.current)) {
return;
}
currentError.current = error;
forceRender();
}
}, [dataRefId, environment, requestContract]);
const dataObserver = useMemo(() => ({
update: dataCallback,
onError: errorCallback,
}), [dataCallback, errorCallback]);
useEffect(() => {
if (environment) {
const schemaNode = requestContract.getSchemaNode();
const { store } = environment;
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
const dataRef = dataNode.getDataRef(dataRefId);
dataRef.subscribe(dataObserver);
return () => dataRef.unsubscribe(dataObserver);
}
return _.noop;
}, [dataObserver, dataRefId, environment, requestContract]);
useEffect(() => {
if (environment && !currentData.current && _.isFunction(lookupFn)) {
const schemaNode = requestContract.getSchemaNode();
const { store } = environment;
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
const dataRef = dataNode.getDataRef(dataRefId);
try {
const overreactIds = getCacheIds({
store,
requestContract,
variables,
lookupFn,
});
if (!_.isEmpty(overreactIds)) {
updateDataRefWithIds(dataRef, overreactIds);
const records = getRecordsById(store, requestContract, overreactIds);
currentData.current = getDataFromRecords(records, responseContract);
requestRequired.current = false;
forceRender();
}
} catch (error) {
// TODO: log error and send request
}
}
// we only try hit cache once when environment is ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environment]);
useDeepEqualEffect(() => {
if (requestRequired.current && environment) {
if (!postponeRead) {
const request = new OverreactRequest({
id: dataRefId,
requestContract,
spec,
variables,
data: null,
});
environment.pushRequest(request);
}
requestRequired.current = false;
}
// TODO: need to unregister this request
}, [dataCallback, environment, requestContract, spec, variables]);
return [currentData.current, currentError.current];
}

Просмотреть файл

@ -0,0 +1,258 @@
import { useCallback, useRef } from 'react';
import { useEnvironment } from '../environment';
import { getRecords, getDataRef, getRecordGroup, getRecordsByEntityKey } from '../store';
import { specTypes } from '../spec/spec-types';
import { OVERREACT_ID_FIELD_NAME } from '../store/consts';
import { responseTypes } from '../spec/response-types';
import { OverreactRequest } from './overreact-request';
import { getMergedConfig } from './merge-config';
function mutateRecords(
store,
requestContract,
recordsBeforeMutation,
newRecords
) {
if (store) {
const recordGroup = getRecordGroup(store, requestContract);
recordGroup.addOrUpdateRecords(recordsBeforeMutation);
recordGroup.addOrUpdateRecords(newRecords);
}
}
function addRecords(
store,
requestContract,
records
) {
if (store && records && records.length > 0) {
const recordGroup = getRecordGroup(store, requestContract);
recordGroup.addOrUpdateRecords(records);
const ids = records.map(record => record[OVERREACT_ID_FIELD_NAME]);
// notify hooks, so that they can adjust the refIds accordingly
recordGroup.notify('entitiesCreated', ids);
}
}
function deleteRecords(store, requestContract, records = []) {
if (store) {
const recordGroup = getRecordGroup(store, requestContract);
const ids = records.map(record => record[OVERREACT_ID_FIELD_NAME]);
recordGroup.deleteRecords(ids);
recordGroup.notify('entitiesDeleted', ids);
}
}
function addPreemptiveRecords(store, requestContract, request, records) {
if (store) {
store.addPreemptiveRecords(request, records);
addRecords(store, requestContract, records);
}
}
function replacePreemptiveRecords(store, requestContract, request, records) {
const preemptiveRecords = store.removePreemptiveRecords(request);
deleteRecords(store, requestContract, preemptiveRecords);
addRecords(store, requestContract, records);
}
function applyId(variables, spec, data, specType) {
const { locator } = variables;
const { requestContract, responseContract } = spec;
const { parentKeySelector } = requestContract;
let parentId = parentKeySelector ? parentKeySelector(variables) : undefined;
const { descriptor, order } = locator;
if (responseContract.responseType === responseTypes.COLL || specType === specTypes.ADD) {
// when requests for COLL or create entity, the parentId is the last element in the locator
if (!parentId && order.length > 0) {
parentId = descriptor[order[order.length - 1]];
}
const dataArr = Array.isArray(data) ? data : [data];
return dataArr.map(entity => responseContract.applyId(entity, parentId));
} else if (responseContract.responseType === responseTypes.ENTITY) {
if (!parentId && order.length > 1) {
parentId = descriptor[order[order.length - 2]];
}
// step 1 - generate _overreact_id
return responseContract.applyId(data, parentId);
}
throw new Error('unknown response type, cannot apply id');
}
export function useMutation(dataRefId, spec, config) {
const {
requestContract,
responseContract,
specType,
environmentLookupFn,
} = spec;
const dataItemsBeforeMutation = useRef();
const environment = useEnvironment(environmentLookupFn);
const dataCallback = useCallback((processedResponse, request) => {
if (environment) {
const { store } = environment;
const dataRef = getDataRef(store, requestContract, dataRefId);
dataRef.clearError();
const {
onComplete,
preemptiveResponseFn,
} = (request && request.mergedConfig) || {};
if (preemptiveResponseFn) {
if (specType === specTypes.MUTATION) {
const current = dataItemsBeforeMutation.current || [];
mutateRecords(
store,
requestContract,
current,
processedResponse
);
} else if (specType === specTypes.ADD) {
replacePreemptiveRecords(
store,
requestContract,
request,
processedResponse
);
}
} else if (specType === specTypes.ADD) {
addRecords(store, requestContract, processedResponse);
} else if (specType === specTypes.MUTATION) {
mutateRecords(
store,
requestContract,
[],
processedResponse
);
}
if (specType === specTypes.DELETE) {
const { data } = request;
const dataArray = Array.isArray(data) ? data : [data];
const { keySelector } = responseContract;
const keys = dataArray.map(d => keySelector(d));
const records = getRecordsByEntityKey(store, spec, keys);
const recordsData = records && records.map(record => record.getData());
deleteRecords(store, requestContract, recordsData);
}
if (onComplete) {
onComplete(processedResponse);
}
}
}, [dataRefId, environment, requestContract, responseContract, spec, specType]);
const errorCallback = useCallback((error, request) => {
if (environment) {
const { store } = environment;
const {
onError,
preemptiveResponseFn,
} = (request && request.mergedConfig) || {};
if (dataItemsBeforeMutation.current) {
// revert change
mutateRecords(
store,
requestContract,
dataItemsBeforeMutation.current,
[]
);
}
if (preemptiveResponseFn) {
if (specType === specTypes.MUTATION) {
const current = dataItemsBeforeMutation.current || [];
mutateRecords(
store,
requestContract,
current,
[]
);
} else if (specType === specTypes.ADD) {
replacePreemptiveRecords(
store,
requestContract,
request,
[]
);
}
}
if (onError) {
onError(error);
}
}
}, [environment, requestContract, specType]);
const mutateFn = useCallback((variables, mutationData, ...rest) => {
if (environment) {
const { store } = environment;
const requestConfig = rest.slice(-1)[0];
const mergedConfig = getMergedConfig(requestConfig, config);
const { preemptiveResponseFn } = mergedConfig;
// stash current value. This will be useful when preemptive updates is enabled,
// and we need to revert to original state before applying actual response from server.
if (dataRefId) {
const recordsBeforeMutation = getRecords(store, requestContract, dataRefId);
if (recordsBeforeMutation) {
dataItemsBeforeMutation.current = recordsBeforeMutation.map(r => r.getData());
}
}
const request = new OverreactRequest({
id: dataRefId,
requestContract,
spec,
variables,
data: mutationData,
dataCb: dataCallback,
errorCb: errorCallback,
mergedConfig,
});
// we can perform preemptive updates right now (before actual request)
// the store will be reverted/merged with response in data callbacks - when the response
// comes back.
if (preemptiveResponseFn) {
if (specType === specTypes.MUTATION) {
const current = dataItemsBeforeMutation.current || [];
mutateRecords(
store,
requestContract,
current,
preemptiveResponseFn(current, mutationData)
);
} else if (specType === specTypes.ADD) {
const data = preemptiveResponseFn(mutationData);
const records = applyId(request.variables, spec, data, specType);
addPreemptiveRecords(
store,
requestContract,
request,
records
);
}
}
// Register the request to be issued - in this implementation the request will go to the
// request queue in DataFetcher, waiting to be invoked. So one shall not assume
// the request will go out immediately.
environment.pushRequest(request);
}
// eslint-disable-next-line max-len
}, [config, dataCallback, dataRefId, environment, errorCallback, requestContract, spec, specType]);
return mutateFn;
}

Просмотреть файл

@ -0,0 +1,223 @@
import _ from 'underscore';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useEnvironment } from '../environment';
import { getDataNode, createDataNode, getRecords, getDataRef, getRecordsById, updateDataRefWithIds } from '../store';
import { OVERREACT_ID_FIELD_NAME } from '../store/consts';
import { OverreactRequest } from './overreact-request';
import { getMergedConfig } from './merge-config';
import { getLookupCacheFn } from './helper';
import { getCacheIds } from './lookup-cache';
const getRecordsDataInDataRef = (store, requestContract, dataRefId) => {
const records = getRecords(store, requestContract, dataRefId);
return records && records.map(record => record.getData());
};
const getRawData = data => data && _.map(data, d => _.omit(d, OVERREACT_ID_FIELD_NAME));
const getRecordsDataById = (store, requestContract, ids) => {
const records = getRecordsById(store, requestContract, ids);
return records && records.map(record => record.getData());
};
export function usePagination(dataRefId, spec, config) {
const {
fetchVariables,
strictMode = false,
mergeNewRecords,
lookupCacheByVariables,
} = config;
const refId = useRef(dataRefId);
const { requestContract, environmentLookupFn } = spec;
const cursorIndex = useRef(0);
const loadingId = useRef(null);
const [isLoading, setIsLoading] = useState(false);
const environment = useEnvironment(environmentLookupFn);
const [data, setData] = useState(undefined);
const [error, setError] = useState(undefined);
const resetInternalState = useCallback(() => {
loadingId.current = null;
setIsLoading(false);
cursorIndex.current = 0;
}, []);
if (refId.current !== dataRefId) {
refId.current = dataRefId;
resetInternalState();
}
const setInternalStateOnResponse = useCallback((records) => {
cursorIndex.current = records.length;
}, []);
// using registerRequest to fire request, we cannot get the total count
// so we set it to true for now
const hasMore = useCallback(() => true, []);
const loadMoreCallback = useCallback((__, updatedIds, request) => {
loadingId.current = null;
setIsLoading(false);
const { store } = environment;
const recordsData = getRecordsDataInDataRef(store, requestContract, dataRefId);
const rawData = getRawData(recordsData);
const dataRef = getDataRef(store, requestContract, dataRefId);
dataRef.clearError();
setInternalStateOnResponse(rawData);
setData(rawData);
const {
onComplete,
} = (request && request.mergedConfig) || {};
if (onComplete) {
onComplete(rawData);
}
}, [dataRefId, environment, requestContract, setData, setInternalStateOnResponse]);
const onErrorCallback = useCallback((__, err, request) => {
if (environment) {
const { store } = environment;
const dataRef = getDataRef(store, requestContract, dataRefId);
const {
status: {
error: currentError,
} = {},
} = dataRef;
if (currentError) {
loadingId.current = null;
setIsLoading(false);
}
setError(currentError);
const {
onError,
} = (request && request.mergedConfig) || {};
if (onError && !_.isUndefined(err)) {
onError(err);
}
}
}, [dataRefId, environment, requestContract]);
const onEntitiesCreated = useCallback((dataRef, newIds) => {
if (_.isFunction(mergeNewRecords)) {
const { store } = environment;
const records = getRecordsDataInDataRef(store, requestContract, dataRefId);
const newRecords = getRecordsDataById(store, requestContract, newIds);
const recordsToShow = mergeNewRecords(records, newRecords);
const ids = recordsToShow.map(record => record[OVERREACT_ID_FIELD_NAME]);
dataRef.reset(ids);
}
}, [dataRefId, environment, mergeNewRecords, requestContract]);
const dataObserver = useMemo(() => ({
update: loadMoreCallback,
onError: onErrorCallback,
onEntitiesCreated,
}), [loadMoreCallback, onEntitiesCreated, onErrorCallback]);
useEffect(() => {
if (environment) {
const schemaNode = requestContract.getSchemaNode();
const { store } = environment;
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
const dataRef = dataNode.getDataRef(dataRefId);
dataRef.subscribe(dataObserver);
return () => dataRef.unsubscribe(dataObserver);
}
return () => {};
}, [dataObserver, dataRefId, environment, requestContract]);
const loadMore = useCallback((requestConfig) => {
if (loadingId.current) {
return;
}
if (!environment) {
return;
}
const { pageSize } = fetchVariables;
const { store } = environment;
const recordsData = getRecordsDataInDataRef(store, requestContract, dataRefId);
const rawData = getRawData(recordsData);
if (rawData && rawData.length >= cursorIndex.current + pageSize) {
cursorIndex.current = rawData.length;
setData(rawData);
return;
}
const lookupFn = getLookupCacheFn(lookupCacheByVariables, spec, requestContract.fetchPolicy);
if (_.isFunction(lookupFn)) {
try {
const overreactIds = getCacheIds({
store,
requestContract,
variables: fetchVariables,
lookupFn,
});
if (!_.isEmpty(overreactIds)) {
const dataRef = getDataRef(store, requestContract, dataRefId);
updateDataRefWithIds(dataRef, overreactIds);
return;
}
} catch (err) {
// TODO: log error and send request
}
}
const requestVars = {
...fetchVariables,
cursorIndex: strictMode
? cursorIndex.current - (cursorIndex.current % pageSize)
: cursorIndex.current,
};
const myId = _.uniqueId();
loadingId.current = myId;
setIsLoading(true);
const mergedConfig = getMergedConfig(requestConfig, config);
const request = new OverreactRequest({
id: dataRefId,
requestContract,
spec,
variables: requestVars,
data: null,
mergedConfig,
});
environment.pushRequest(request);
}, [
config,
dataRefId,
environment,
fetchVariables,
lookupCacheByVariables,
requestContract,
spec,
strictMode,
]);
const ret = useMemo(() => [{ data, error }, {
isLoading,
hasMore,
loadMore,
}], [data, error, hasMore, isLoading, loadMore]);
return ret;
}

Просмотреть файл

@ -0,0 +1,11 @@
import { useRef, useEffect } from 'react';
export function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

Просмотреть файл

@ -0,0 +1,26 @@
import { useMemo } from 'react';
import Promise from 'bluebird';
export function usePromisify(actions) {
return useMemo(() =>
actions.map(action => (...args) => new Promise((resolve, reject) => {
const configBuilder = config => ({
...config,
onComplete: (processedResponse) => {
if (config.onComplete) {
config.onComplete(processedResponse);
}
resolve(processedResponse);
},
onError: (error) => {
if (config.onError) {
config.onError(error);
}
reject(error);
},
});
const mergedArgs = [...args, configBuilder];
action(...mergedArgs);
})), actions); // eslint-disable-line react-hooks/exhaustive-deps
}

Просмотреть файл

@ -0,0 +1,110 @@
import _ from 'underscore';
import { useCallback } from 'react';
import { useEnvironment } from '../environment';
import { FetchPolicy } from '../middleware';
import { getRecords, getDataRef, updateDataRefWithIds } from '../store';
import { OverreactRequest } from './overreact-request';
import { getMergedConfig } from './merge-config';
import { getCacheIds } from './lookup-cache';
import { getDataFromRecords, getLookupCacheFn } from './helper';
export function useRefetch(dataRefId, spec, config) {
const {
requestContract,
responseContract,
environmentLookupFn,
} = spec;
const environment = useEnvironment(environmentLookupFn);
const dataCallback = useCallback((dataItems, request) => {
if (environment) {
const {
onComplete,
} = (request && request.mergedConfig) || {};
const { store } = environment;
const records = getRecords(store, requestContract, dataRefId);
const data = getDataFromRecords(records, responseContract);
if (onComplete) {
onComplete(data);
}
}
}, [dataRefId, environment, requestContract, responseContract]);
const errorCallback = useCallback((error, request) => {
const {
onError = _.noop,
} = (request && request.mergedConfig) || {};
onError(error);
}, []);
const refetchFn = useCallback((parameter, ...rest) => {
if (environment) {
const { store } = environment;
const requestConfig = rest.slice(-1)[0];
const mergedConfig = getMergedConfig(requestConfig, config);
const { lookupCacheByVariables } = mergedConfig;
const {
variables,
payload,
} = parameter;
const { options: { fetchPolicy: fetchPolicyInReq } = {} } = variables || {};
const fetchPolicy =
fetchPolicyInReq || requestContract.fetchPolicy || FetchPolicy.NetworkOnly;
const request = new OverreactRequest({
id: dataRefId,
requestContract,
spec,
variables,
data: payload,
dataCb: dataCallback,
errorCb: errorCallback,
mergedConfig,
});
const lookupFn = getLookupCacheFn(lookupCacheByVariables, spec, fetchPolicy);
if (_.isFunction(lookupFn)) {
try {
const overreactIds = getCacheIds({
store,
requestContract,
variables,
lookupFn,
});
if (!_.isEmpty(overreactIds)) {
const dataRef = getDataRef(store, requestContract, dataRefId);
updateDataRefWithIds(dataRef, overreactIds);
const records = getRecords(store, requestContract, dataRefId);
const data = getDataFromRecords(records, responseContract);
dataCallback(data, request);
return;
}
} catch (error) {
// TODO: log error and send request
}
}
environment.pushRequest(request);
}
}, [
config,
dataCallback,
dataRefId,
environment,
errorCallback,
requestContract,
responseContract,
spec,
]);
return refetchFn;
}

Просмотреть файл

@ -0,0 +1,32 @@
import _ from 'underscore';
export function createErrorMiddleware(errorMiddlewareOptions) {
const {
errorProcessor = _.noop,
errorConvertor: envErrorConverter = _.noop,
} = errorMiddlewareOptions || {};
return next => async (req) => {
const {
spec: {
responseContract: {
errorConvertFn,
} = {},
} = {},
} = req || {};
const errorConvertor = errorConvertFn || envErrorConverter;
const response = next(req).then((res) => {
const error = errorConvertor(res);
if (error) {
throw error;
}
return res;
}).catch((error) => {
const processedError = errorConvertor(error) || error;
errorProcessor(processedError);
throw error;
});
return response;
};
}

Просмотреть файл

@ -0,0 +1,82 @@
import _ from 'underscore';
import {
FetchPolicy,
IsNetworkPolicy,
IsStorePolicy,
IsStoreSecondaryPolicy,
getFetchPolicy,
getDataFromStore,
shouldForceNetwork,
updateDataRefStatus,
DEFAULT_STORE_EXPIRATION_DURATION,
} from './fetch-policy-utils';
export function createFetchPolicyMiddleware(fetchPolicyMiddlewareOptions) {
const {
fetchPolicy = FetchPolicy.StoreOrNetwork,
cacheExpirationDuration = DEFAULT_STORE_EXPIRATION_DURATION,
} = fetchPolicyMiddlewareOptions || {};
return next => async (req) => {
const fetchPolicyInReq =
(req.variables && req.variables.options && req.variables.options.fetchPolicy)
|| req.spec.requestContract.fetchPolicy;
const requestFetchPolicy =
getFetchPolicy(req.spec.specType, fetchPolicyInReq, fetchPolicy);
const isStoreSecondaryPolicy = IsStoreSecondaryPolicy(requestFetchPolicy);
let dataInStore = null;
const isNetworkPolicy = IsNetworkPolicy(requestFetchPolicy);
const isStorePolicy = IsStorePolicy(requestFetchPolicy);
const currentTimestamp = Date.now();
const isForceNetwork = shouldForceNetwork({
store: req.store,
spec: req.spec,
dataRefId: req.dataRefId,
variables: _.omit(req.variables, 'options'),
storeExpirationDuration: cacheExpirationDuration,
currentTimestamp,
requestFetchPolicy,
});
if (isStorePolicy && !isForceNetwork && !isStoreSecondaryPolicy) {
dataInStore = getDataFromStore(req.store, req.spec, req.dataRefId);
if (!_.isEmpty(dataInStore)) {
req.middlewareStates.isResponseFromStore = true;
return dataInStore;
}
}
if (isNetworkPolicy) {
const res = await next(req)
.then((response) => {
updateDataRefStatus({
store: req.store,
spec: req.spec,
dataRefId: req.dataRefId,
variables: _.omit(req.variables, 'options'),
currentTimestamp,
});
return response;
})
.catch((error) => {
// we only try roll back to previous data if policy is networkOrStore
// if policy is storeOrNetwork and we go here, it means we already miss the cache
// or we decided to not trust the cache.
if (requestFetchPolicy === FetchPolicy.NetworkOrStore) {
dataInStore = getDataFromStore(req.store, req.spec, req.dataRefId);
if (!_.isEmpty(dataInStore)) {
req.middlewareStates.isResponseFromStore = true;
return dataInStore;
}
}
throw error;
});
return res;
}
return next(req);
};
}

Просмотреть файл

@ -0,0 +1,82 @@
import _ from 'underscore';
import stringify from 'json-stable-stringify';
import { getRecords, getDataRef } from '../../store';
import { specTypes } from '../../spec/spec-types';
import { responseTypes } from '../../spec/response-types';
export const DEFAULT_STORE_EXPIRATION_DURATION = 5 * 60 * 1000;
export const FetchPolicy = {
StoreOnly: 'StoreOnly',
NetworkOnly: 'NetworkOnly',
StoreOrNetwork: 'StoreOrNetwork',
NetworkOrStore: 'NetworkOrStore',
};
export function IsNetworkPolicy(fetchPolicy) {
return fetchPolicy === FetchPolicy.NetworkOnly
|| fetchPolicy === FetchPolicy.NetworkOrStore
|| fetchPolicy === FetchPolicy.StoreOrNetwork;
}
export function IsStorePolicy(fetchPolicy) {
return fetchPolicy === FetchPolicy.StoreOnly
|| fetchPolicy === FetchPolicy.StoreOrNetwork
|| fetchPolicy === FetchPolicy.NetworkOrStore;
}
export function IsStoreSecondaryPolicy(fetchPolicy) {
return fetchPolicy === FetchPolicy.NetworkOrStore;
}
export function getFetchPolicy(specType, fetchPolicyOption, fetchPolicyInEnv) {
if (specType === specTypes.FETCH
|| specType === specTypes.PAGINATION
|| fetchPolicyOption !== FetchPolicy.StoreOnly) {
return fetchPolicyOption || fetchPolicyInEnv;
}
return fetchPolicyInEnv;
}
export function getDataFromStore(store, spec, dataRefId) {
const { requestContract } = spec;
const records = getRecords(store, requestContract, dataRefId);
const dataInStore = _.map(records, record => record.getData());
if (spec.responseContract.responseType === responseTypes.ENTITY
&& !_.isEmpty(dataInStore) && _.isArray(dataInStore)) {
return dataInStore[0];
}
return dataInStore;
}
export function shouldForceNetwork({
store, spec, dataRefId, variables, storeExpirationDuration, currentTimestamp, requestFetchPolicy,
}) {
const { requestContract } = spec;
const dataRef = getDataRef(store, requestContract, dataRefId);
const {
status: {
previousVariables,
lastUpdateTimestamp,
} = {},
} = dataRef || {};
return requestFetchPolicy !== FetchPolicy.StoreOnly
&& (stringify(variables) !== stringify(previousVariables)
|| currentTimestamp - lastUpdateTimestamp > storeExpirationDuration);
}
export function updateDataRefStatus({
store, spec, dataRefId, variables, currentTimestamp,
}) {
const { requestContract } = spec;
const dataRef = getDataRef(store, requestContract, dataRefId);
if (dataRef) {
dataRef.updateStatus({
previousVariables: variables,
lastUpdateTimestamp: currentTimestamp,
});
}
}

Просмотреть файл

@ -0,0 +1,7 @@
export { createFetchPolicyMiddleware } from './fetch-policy/fetch-policy-middleware';
export { FetchPolicy } from './fetch-policy/fetch-policy-utils';
export { requestWithMiddleware } from './request-with-middleware';
export { WrappedRequestor } from './wrapped-requestor';
export { middlewareTypes } from './middleware-types';
export { createErrorMiddleware } from './error/error-middleware';
export { createInstrumentationMiddleware } from './instrumentation/instrumentation-middleware';

Просмотреть файл

@ -0,0 +1,23 @@
import _ from 'underscore';
import { defaultStubOptions } from './instrumentation-utils';
export class InstrumentationContext {
constructor(parameter) {
const {
pageTrackingId,
errorMappers,
url,
requestId,
httpMethod,
stubOptions,
} = parameter;
this.pageTrackingId = pageTrackingId;
this.errorMappers = errorMappers;
this.url = url;
this.requestId = requestId;
this.httpMethod = httpMethod;
this.stubOptions = _.defaults(stubOptions || {}, defaultStubOptions);
}
}

Просмотреть файл

@ -0,0 +1,54 @@
/* eslint-disable no-param-reassign */
import _ from 'underscore';
import { v4 as uuidv4 } from 'uuid';
import { InstrumentationContext } from './instrumentation-context';
import { beforeSendHandler, successHandler, errorHandler } from './instrumentation-utils';
export function createInstrumentationMiddleware(instrumentationOptions) {
const {
pageTrackingId,
errorMappers,
stubOptions,
loggerFunc: {
traceFunc = _.noop,
errorFunc = _.noop,
perfFunc = _.noop,
} = {},
} = instrumentationOptions || {};
const shouldAddHeaders = instrumentationOptions.shouldAddHeaders || _.constant(true);
function isUserError(instrumentationContext) {
return _.any(errorMappers, errorMapper => errorMapper.check(instrumentationContext));
}
return next => async (req) => {
const {
header: {
'x-ms-requestid': requestId = uuidv4(),
} = {},
} = req;
const instrumentationContext = new InstrumentationContext({
pageTrackingId,
errorMappers,
url: req.uri,
requestId,
httpMethod: req.spec.requestContract.verb,
stubOptions,
});
beforeSendHandler(instrumentationContext, req, perfFunc, shouldAddHeaders);
const response = next(req).then((res) => {
successHandler(instrumentationContext, res, errorFunc, perfFunc);
return res;
}).catch((error) => {
errorHandler(instrumentationContext, error, isUserError, traceFunc, errorFunc, perfFunc);
throw error;
});
return response;
};
}

Просмотреть файл

@ -0,0 +1,161 @@
/* eslint-disable no-param-reassign */
import _ from 'underscore';
import { getTimestamp } from '@bingads-webui-universal/primitive-utilities';
export const defaultStubOptions = {
serverErrorCodes: [-1],
detectError(respData, serverErrorCodes) {
const result = {
pass: true,
};
if (respData &&
respData.Errors &&
respData.Errors[0] &&
respData.Errors[0].Code &&
_.isArray(serverErrorCodes) &&
_.contains(serverErrorCodes, respData.Errors[0].Code)) {
result.pass = false;
result.message = respData.Errors[0].Message;
result.impactUser = true;
}
return result;
},
getServerPerf(response) {
if (response && _.isFunction(response.getResponseHeader)) {
const perfTimings = response.getResponseHeader('PerfTimings');
if (perfTimings) {
return perfTimings;
}
const perf = {};
_.each(
['x-ms-mte2eelapsedtimems', 'x-ms-odataapie2eelapsedtimems', 'x-ms-odataapionlye2eelapsedtimems'],
(header) => {
const value = response.getResponseHeader(header);
if (value) {
perf[header] = value;
}
}
);
return JSON.stringify(perf);
}
return '';
},
};
export function beforeSendHandler(instrumentationContext, req, perfFunc, shouldAddHeaders) {
instrumentationContext.requestStartTime = getTimestamp();
if (shouldAddHeaders(instrumentationContext) === true) {
req.header = {
...req.header,
'x-ms-pagetrackingid': instrumentationContext.pageTrackingId,
'x-ms-lcid': instrumentationContext.stubOptions.lcid,
lcid: instrumentationContext.stubOptions.lcid,
'x-ms-requestid': instrumentationContext.requestId,
};
}
perfFunc({
requestId: instrumentationContext.requestId,
api: instrumentationContext.url,
isMethodEnter: true,
httpMethod: instrumentationContext.httpMethod,
timeTaken: 0,
pass: true,
message: '',
});
}
export function successHandler(instrumentationContext, response, errorFunc, perfFunc) {
const requestTimeTaken = getTimestamp() - instrumentationContext.requestStartTime;
const result = instrumentationContext.stubOptions.detectError(response);
instrumentationContext.requestTimeTaken = requestTimeTaken;
if (!result.pass) {
instrumentationContext.requestResult = false;
instrumentationContext.error = result.message;
errorFunc({
message: instrumentationContext.error,
api: instrumentationContext.url,
requestId: instrumentationContext.requestId,
impactUser: result.impactUser,
httpMethod: instrumentationContext.httpMethod,
});
}
perfFunc({
requestId: instrumentationContext.requestId,
api: instrumentationContext.url,
isMethodEnter: false,
httpMethod: instrumentationContext.httpMethod,
timeTaken: instrumentationContext.requestTimeTaken,
pass: instrumentationContext.requestResult,
message: instrumentationContext.stubOptions.getServerPerf(response),
});
}
export function errorHandler(
instrumentationContext, error, isUserError,
traceFunc, errorFunc, perfFunc
) {
const requestTimeTaken = getTimestamp() - instrumentationContext.requestStartTime;
instrumentationContext.requestTimeTaken = requestTimeTaken;
instrumentationContext.responseJSON = error.responseJSON;
if (error.status !== 0 && error.status) {
instrumentationContext.requestResult = false;
instrumentationContext.error = `Overreact server response error: [${error.status}]`;
if (error.textStatus && error.textStatus.trim() !== '') {
instrumentationContext.error += (`, ${error.textStatus}`);
if (error.responseText && error.responseText.trim() !== '') {
instrumentationContext.error += (`, ${error.responseText}`);
} else if (error.responseXML && error.responseXML.trim() !== '') {
instrumentationContext.error += (`, ${error.responseXML}`);
}
}
switch (error.status) {
case 400:
case 404:
// If error maps to a user error, log at trace level
if (isUserError(instrumentationContext)) {
instrumentationContext.requestResult = true;
}
break;
case 401:
// Log 401 unauthorized error at trace level
instrumentationContext.requestResult = true;
break;
default:
instrumentationContext.requestResult = false;
}
} else {
instrumentationContext.requestResult = false;
instrumentationContext.error = `Overreact general error: [${JSON.stringify(error)}]`;
}
const logFunc = instrumentationContext.requestResult ? traceFunc : errorFunc;
logFunc({
message: instrumentationContext.error,
api: instrumentationContext.url,
requestId: instrumentationContext.requestId,
httpMethod: instrumentationContext.httpMethod,
});
perfFunc({
requestId: instrumentationContext.requestId,
api: instrumentationContext.url,
isMethodEnter: false,
httpMethod: instrumentationContext.httpMethod,
timeTaken: instrumentationContext.requestTimeTaken,
pass: instrumentationContext.requestResult,
});
}

Просмотреть файл

@ -0,0 +1,5 @@
export const middlewareTypes = {
ERROR: 'ERROR',
FETCH_POLICY: 'FETCH_POLICY',
INSTRUMENTATION: 'INSTRUMENTATION',
};

Просмотреть файл

@ -0,0 +1,20 @@
import { compose, omit, values, pick } from 'underscore';
import { middlewareTypes } from './middleware-types';
function executeRequestor(wrappedRequestor) {
return wrappedRequestor.executeRequest();
}
export function requestWithMiddleware(wrappedRequestor, middlewares) {
const baseMiddlewares = values(omit(middlewares, middlewareTypes.INSTRUMENTATION));
const instrumentationMiddleware = values(pick(middlewares, middlewareTypes.INSTRUMENTATION));
const wrappedRequest =
compose(...baseMiddlewares, ...instrumentationMiddleware)(executeRequestor);
// eslint-disable-next-line arrow-body-style
return wrappedRequest(wrappedRequestor).then((response) => {
return response;
}).catch((error) => {
throw error;
});
}

Просмотреть файл

@ -0,0 +1,33 @@
export class WrappedRequestor {
constructor(parameter) {
const {
requestor,
uri,
verb,
header,
payload,
spec,
variables,
store,
dataRefId,
middlewareStates,
} = parameter;
this.requestor = requestor;
this.uri = uri;
this.verb = verb;
this.header = header;
this.payload = payload;
this.variables = variables;
this.spec = spec;
this.store = store;
this.dataRefId = dataRefId;
this.middlewareStates = middlewareStates;
this.executeRequest = this.executeRequest.bind(this);
}
executeRequest() {
return this.requestor(this.uri, this.verb, this.header, this.payload);
}
}

Просмотреть файл

@ -0,0 +1,63 @@
import { SchemaNode } from './schema-node';
const PATH_DELIMITER = ':';
export class Schema {
constructor(schemaToModelMapping, modelGetter) {
this.root = new SchemaNode('$ROOT', null, null);
this.schemaToModelMapping = schemaToModelMapping;
this.modelGetter = modelGetter;
this.modelToSchemaMapping = {};
this.buildModelToSchemaMap();
this.insert = this.insert.bind(this);
this.getSchemaNames = this.getSchemaNames.bind(this);
}
buildModelToSchemaMap() {
const keys = Object.keys(this.schemaToModelMapping);
keys.forEach((schemaName) => {
const modelName = this.schemaToModelMapping[schemaName];
if (!this.modelToSchemaMapping[modelName]) {
this.modelToSchemaMapping[modelName] = [];
}
this.modelToSchemaMapping[modelName].push(schemaName);
});
}
getSchemaNames(modelName) {
return this.modelToSchemaMapping[modelName];
}
insert(path) {
const parts = path.split(PATH_DELIMITER);
let currentNode = this.root;
for (let i = 0; i < parts.length; i += 1) {
const partName = parts[i];
let existingPathFound = false;
for (let j = 0; j < currentNode.childNodes.length; j += 1) {
const t = currentNode.childNodes[j];
if (t.name === partName) {
currentNode = t;
existingPathFound = true;
break;
}
}
if (!existingPathFound) {
const modelName = this.schemaToModelMapping[partName];
const modelSchema = this.modelGetter(this.schemaToModelMapping[partName]);
const newNode = new SchemaNode(partName, modelName, currentNode, modelSchema);
currentNode.childNodes.push(newNode);
currentNode = newNode;
}
}
return currentNode;
}
}

Просмотреть файл

@ -0,0 +1,32 @@
export class SchemaNode {
constructor(name, modelName, parentNode, modelSchema) {
this.name = name;
this.childNodes = [];
this.parentNode = parentNode;
this.modelName = modelName;
this.modelSchema = modelSchema;
// extensions will serve as an extension point
// where we can attach arbitrary information to
// a schema node.
// this is useful when we want to extend the schema
// tree to include data.
this.extensions = {};
this.append = this.append.bind(this);
this.setExtension = this.setExtension.bind(this);
this.getExtension = this.getExtension.bind(this);
}
append(node) {
this.childNodes.push(node);
}
setExtension(name, ext) {
this.extensions[name] = ext;
}
getExtension(name) {
return this.extensions[name];
}
}

Просмотреть файл

@ -0,0 +1,7 @@
export * from './response-types';
export * from './spec-types';
export * from './request-verbs';
export * from './request-contract';
export * from './response-contract';
export * from './spec';

Просмотреть файл

@ -0,0 +1,63 @@
export class RequestContract {
constructor({
schema,
dataPath,
verb,
fetchPolicy,
uriFactoryFn,
headerFactoryFn,
payloadFactoryFn,
keySelector,
parentKeySelector,
}) {
this.schema = schema;
this.dataPath = dataPath;
this.verb = verb;
this.fetchPolicy = fetchPolicy;
this.uriFactoryFn = uriFactoryFn;
this.headerFactoryFn = headerFactoryFn;
this.payloadFactoryFn = payloadFactoryFn;
this.keySelector = keySelector;
this.parentKeySelector = parentKeySelector;
this.getSchemaNode = this.getSchemaNode.bind(this);
}
getSchemaNode() {
// schema is the root of the schema tree that current app has built
// need to either find an existing path in the tree,
// or construct a new path in the tree
// note that the schema tree is only a sub-tree (or more precisely a sub-graph)
// of the original data schema, such as one found in OData.
return this.schema.insert(this.dataPath);
}
}
export function createRequestContract({
schema,
dataPath,
verb,
fetchPolicy,
uriFactoryFn,
headerFactoryFn,
payloadFactoryFn,
keySelector,
parentKeySelector,
}) {
if (!dataPath) {
throw new Error('dataPath cannot be empty');
}
return new RequestContract({
schema,
dataPath,
verb,
fetchPolicy,
uriFactoryFn,
headerFactoryFn,
payloadFactoryFn,
keySelector,
parentKeySelector,
});
}

Просмотреть файл

@ -0,0 +1,7 @@
export const requestVerbs = {
GET: 'GET',
POST: 'POST',
DELETE: 'DELETE',
PATCH: 'PATCH',
PUT: 'PUT',
};

Просмотреть файл

@ -0,0 +1,94 @@
/* eslint-disable no-console */
/* eslint-disable max-len */
import { OVERREACT_ID_FIELD_NAME, createOverreactId } from '../store/consts';
import { specTypes } from './spec-types';
import fetchResponseHandler from './response-handler/fetch';
import mutationResponseHandler from './response-handler/mutation';
import refetchResponseHandler from './response-handler/refetch';
import errorResponseHandler from './response-handler/error';
export class ResponseContract {
constructor({
requestContract, responseType, keySelector, processorFn, errorConvertFn,
}) {
this.requestContract = requestContract;
this.responseType = responseType;
this.keySelector = keySelector;
this.processorFn = processorFn;
this.errorConvertFn = errorConvertFn;
this.schemaNode = this.requestContract.getSchemaNode();
this.selectKey = this.selectKey.bind(this);
this.onGetResponse = this.onGetResponse.bind(this);
this.onGetError = this.onGetError.bind(this);
this.applyId = this.applyId.bind(this);
}
selectKey(variables) {
return this.keySelector(variables);
}
applyId(entity, parentId) {
// create a new OVERREACT_ID_FIELD in the entity object,
// to uniquely identify a record.
// Basically we'll append the entity's parent type and ID
// to the ID of the entity itself, which will comes handy
// during the clean-up if the parent entity is deleted.
const overreactId = createOverreactId(
this.keySelector(entity),
parentId,
this.schemaNode.parentNode.name
);
return {
...entity,
[OVERREACT_ID_FIELD_NAME]: overreactId,
};
}
onGetResponse(environment, response, request) {
let processedResponse = response;
const { spec } = request;
const { specType } = spec;
if (!request.middlewareStates.isResponseFromStore && this.processorFn) {
processedResponse = this.processorFn(response, request);
}
switch (specType) {
case specTypes.FETCH:
case specTypes.PAGINATION: {
return fetchResponseHandler(environment, processedResponse, request)(this);
}
case specTypes.ADD:
case specTypes.DELETE:
case specTypes.MUTATION: {
return mutationResponseHandler(environment, processedResponse, request)(this);
}
case specTypes.REFETCH: {
return refetchResponseHandler(environment, processedResponse, request)(this);
}
default:
return null;
}
}
onGetError(environment, request, error) {
return errorResponseHandler(environment, request, error)(this);
}
}
export function createResponseContract({
requestContract,
responseType,
keySelector,
processorFn,
errorConvertFn,
}) {
return new ResponseContract({
requestContract, responseType, keySelector, processorFn, errorConvertFn,
});
}

Просмотреть файл

@ -0,0 +1,24 @@
import _ from 'underscore';
import { getDataNode, createDataNode } from '../../store';
export default function handler(environment, request, error) {
return (context) => {
const { id: dataRefId, errorCb } = request;
const { store } = environment;
let dataNode = getDataNode(context.schemaNode);
if (!dataNode) {
dataNode = createDataNode(context.schemaNode, store);
}
const dataRef = dataNode.getDataRef(dataRefId);
dataRef.onError(error, request);
if (_.isFunction(errorCb)) {
errorCb(error, request);
}
};
}

Просмотреть файл

@ -0,0 +1,76 @@
import _ from 'underscore';
import { getDataNode, createDataNode } from '../../store';
import { responseTypes } from '../response-types';
import { OVERREACT_ID_FIELD_NAME } from '../../store/consts';
import { getSideEffectCacheStoreHelpers } from './sideEffectFnHelper';
export default function handler(environment, processedResponse, request) {
return (context) => {
const { id: dataRefId, variables, spec } = request;
const { locator } = variables;
const { store } = environment;
let dataNode = getDataNode(context.schemaNode);
if (!dataNode) {
dataNode = createDataNode(context.schemaNode, store);
}
const dataRef = dataNode.getDataRef(dataRefId);
dataRef.clearError();
const { descriptor, order } = locator;
const { requestContract, sideEffectFn } = spec;
const { parentKeySelector } = requestContract;
let parentId = parentKeySelector ? parentKeySelector(variables) : undefined;
let dataWithId = null;
// after the response has been processed, it will either be
// - a single entity
// - an array of entities
if (context.responseType === responseTypes.ENTITY) {
if (!parentId && order.length > 1) {
parentId = descriptor[order[order.length - 2]];
}
// step 1 - generate _overreact_id
dataWithId = context.applyId(processedResponse, parentId);
const overreactId = dataWithId[OVERREACT_ID_FIELD_NAME];
// step 2 - add/merge _overreact_id to current dataRef
dataRef.add(overreactId);
// step 3 - insert the data into store, this will trigger callbacks
// in data nodes, which will in turn trigger dataRef refresh.
store.getRecordGroup(context.schemaNode.modelName)
.addOrUpdateRecords([dataWithId], request);
} else if (context.responseType === responseTypes.COLL) {
// when requests for COLL, the parentId is the last element in the locator
if (!parentId && order.length > 0) {
parentId = descriptor[order[order.length - 1]];
}
dataWithId = processedResponse.map((entity) => {
const data = context.applyId(entity, parentId);
const overreactId = data[OVERREACT_ID_FIELD_NAME];
dataRef.add(overreactId);
return data;
});
store.getRecordGroup(context.schemaNode.modelName)
.addOrUpdateRecords(dataWithId, request);
// when the return value is empty, we still need to notify the hook who trigger this call
if (dataWithId.length === 0) {
dataRef.notify('update', [], request);
}
}
if (sideEffectFn && !_.isEmpty(dataWithId)) {
const cacheStoreHelper = getSideEffectCacheStoreHelpers(environment);
sideEffectFn(dataWithId, request, spec, cacheStoreHelper);
}
};
}

Просмотреть файл

@ -0,0 +1,45 @@
import { responseTypes } from '../response-types';
import { specTypes } from '../spec-types';
import { getSideEffectCacheStoreHelpers } from './sideEffectFnHelper';
export default function handler(environment, processedResponse, request) {
return (context) => {
const { variables, dataCb, spec } = request;
const { locator } = variables;
const { requestContract, sideEffectFn } = spec;
const { parentKeySelector } = requestContract;
let parentId = parentKeySelector ? parentKeySelector(variables) : undefined;
let dataWithId = null;
const { descriptor, order } = locator;
if (context.responseType === responseTypes.COLL || request.spec.specType === specTypes.ADD) {
// when requests for COLL or create entity, the parentId is the last element in the locator
if (!parentId && order.length > 0) {
parentId = descriptor[order[order.length - 1]];
}
const responseArr = Array.isArray(processedResponse)
? processedResponse
: [processedResponse];
dataWithId = responseArr.map(entity => context.applyId(entity, parentId));
dataCb(dataWithId, request);
} else if (context.responseType === responseTypes.ENTITY) {
if (!parentId && order.length > 1) {
parentId = descriptor[order[order.length - 2]];
}
// step 1 - generate _overreact_id
dataWithId = context.applyId(processedResponse, parentId);
dataCb([dataWithId], request);
} else if (context.responseType === responseTypes.NONE) {
// TODO: we need to deal with DELETE
dataCb(null, request);
}
if (sideEffectFn) {
const cacheStoreHelper = getSideEffectCacheStoreHelpers(environment);
sideEffectFn(dataWithId, request, spec, cacheStoreHelper);
}
};
}

Просмотреть файл

@ -0,0 +1,70 @@
import { getDataNode, createDataNode } from '../../store';
import { responseTypes } from '../response-types';
import { OVERREACT_ID_FIELD_NAME } from '../../store/consts';
import { getSideEffectCacheStoreHelpers } from './sideEffectFnHelper';
export default function handler(environment, processedResponse, request) {
return (context) => {
const {
id: dataRefId,
variables,
dataCb,
spec,
} = request;
const { locator } = variables;
const { store } = environment;
let dataNode = getDataNode(context.schemaNode);
if (!dataNode) {
dataNode = createDataNode(context.schemaNode, store);
}
const dataRef = dataNode.getDataRef(dataRefId);
dataRef.clear();
const { descriptor, order } = locator;
const { requestContract, sideEffectFn } = spec;
const { parentKeySelector } = requestContract;
let parentId = parentKeySelector ? parentKeySelector(variables) : undefined;
let dataWithId = null;
if (context.responseType === responseTypes.ENTITY) {
if (!parentId && order.length > 1) {
parentId = descriptor[order[order.length - 2]];
}
dataWithId = context.applyId(processedResponse, parentId);
const overreactId = dataWithId[OVERREACT_ID_FIELD_NAME];
dataRef.add(overreactId);
store.getRecordGroup(context.schemaNode.modelName)
.addOrUpdateRecords([dataWithId], request);
dataCb(dataWithId, request);
} else if (context.responseType === responseTypes.COLL) {
if (!parentId && order.length > 0) {
parentId = descriptor[order[order.length - 1]];
}
dataWithId = processedResponse.map((entity) => {
const data = context.applyId(entity, parentId);
const overreactId = data[OVERREACT_ID_FIELD_NAME];
dataRef.add(overreactId);
return data;
});
store.getRecordGroup(context.schemaNode.modelName)
.addOrUpdateRecords(dataWithId, request);
dataCb(dataWithId, request);
}
if (sideEffectFn) {
const cacheStoreHelper = getSideEffectCacheStoreHelpers(environment);
sideEffectFn(dataWithId, request, spec, cacheStoreHelper);
}
};
}

Просмотреть файл

@ -0,0 +1,66 @@
import _ from 'underscore';
import { OVERREACT_ID_FIELD_NAME, createOverreactId } from '../../store/consts';
export function getSideEffectCacheStoreHelpers(environment) {
const { store, schema: { schemaToModelMapping } } = environment;
const tryMergeItemsToCacheStore = ({
items,
schemaName,
itemKeySelector,
parentSchameName,
parentId,
}) => {
const modelName = schemaToModelMapping[schemaName];
const recordGroup = store.getRecordGroup(modelName);
const keysInCache = recordGroup.records.map(r => itemKeySelector(r.data));
const itemsToAdd = _.filter(items, item => !_.include(keysInCache, itemKeySelector(item)));
const itemsToAddWithOverreactId = itemsToAdd.map((item) => {
const overreactId = createOverreactId(
itemKeySelector(item),
parentId,
parentSchameName
);
return {
...item,
[OVERREACT_ID_FIELD_NAME]: overreactId,
};
});
recordGroup.addOrUpdateRecords(itemsToAddWithOverreactId);
const ids = itemsToAddWithOverreactId.map(item => item[OVERREACT_ID_FIELD_NAME]);
recordGroup.notify('entitiesCreated', ids);
const itemsToMerge = _.filter(items, item => _.include(keysInCache, itemKeySelector(item)));
const itemsMerged = itemsToMerge.map((itemToMerge) => {
const key = itemKeySelector(itemToMerge);
const records = recordGroup.getRecordsByEntityKeys(itemKeySelector, [key]);
return {
...records[0].data,
...itemToMerge,
[OVERREACT_ID_FIELD_NAME]: records[0].id,
};
});
recordGroup.addOrUpdateRecords(itemsMerged);
};
const tryDeleteItemsInCacheStore = ({
keysToDelete,
schemaName,
itemKeySelector,
}) => {
const modelName = schemaToModelMapping[schemaName];
const recordGroup = store.getRecordGroup(modelName);
const recordsToRemove = recordGroup.getRecordsByEntityKeys(itemKeySelector, keysToDelete);
const recordsIdsToRemove = recordsToRemove.map(r => r.id);
recordGroup.deleteRecords(recordsIdsToRemove);
recordGroup.notify('entitiesDeleted', recordsIdsToRemove);
};
return {
tryMergeItemsToCacheStore,
tryDeleteItemsInCacheStore,
};
}

Просмотреть файл

@ -0,0 +1,5 @@
export const responseTypes = {
ENTITY: 'ENTITY',
COLL: 'COLL',
NONE: 'NONE', // useful when deleting
};

Просмотреть файл

@ -0,0 +1,8 @@
export const specTypes = {
ADD: 'ADD',
DELETE: 'DELETE',
FETCH: 'FETCH',
MUTATION: 'MUTATION',
PAGINATION: 'PAGINATION',
REFETCH: 'REFETCH',
};

Просмотреть файл

@ -0,0 +1,16 @@
export function createSpec(
requestContract,
responseContract,
specType,
sideEffectFn,
environmentLookupFn
) {
// FIXME: only the basics. Need to create more based on specType
return {
requestContract,
responseContract,
specType,
sideEffectFn,
environmentLookupFn,
};
}

Просмотреть файл

@ -0,0 +1,5 @@
export const OVERREACT_ID_FIELD_NAME = '_OVERREACT_ID';
export function createOverreactId(id, parentId, parentType) {
return `${parentType}:${parentId}:${id}`;
}

Просмотреть файл

@ -0,0 +1,10 @@
export * from './schema-extension/schema-extension';
export * from './store';
export {
getRecordGroup,
getRecordsById,
getRecordsByEntityKey,
getRecords,
getDataRef,
updateDataRefWithIds,
} from './record-group-helper';

Просмотреть файл

@ -0,0 +1,54 @@
import _ from 'underscore';
import { getDataNode, createDataNode } from './schema-extension/schema-extension';
export function getRecordGroup(store, requestContract) {
if (store) {
const schemaNode = requestContract.getSchemaNode();
const recordGroup = store.getRecordGroup(schemaNode.modelName);
return recordGroup;
}
return null;
}
export function getRecordsById(store, requestContract, ids) {
const recordGroup = getRecordGroup(store, requestContract);
return recordGroup.getRecords(ids);
}
export function getRecordsByEntityKey(store, spec, keys) {
const { requestContract, responseContract } = spec;
const recordGroup = getRecordGroup(store, requestContract);
const { keySelector } = responseContract;
return recordGroup.getRecordsByEntityKeys(keySelector, keys);
}
export function getRecords(store, requestContract, key) {
if (store) {
const schemaNode = requestContract.getSchemaNode();
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
const dataIds = dataNode.getDataRef(key).idRefs;
const recordGroup = getRecordGroup(store, requestContract);
return recordGroup.getRecords(dataIds);
}
return null;
}
export function getDataRef(store, requestContract, dataRefId) {
if (store) {
const schemaNode = requestContract.getSchemaNode();
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
return dataNode && dataNode.getDataRef(dataRefId);
}
return null;
}
export function updateDataRefWithIds(dataRef, ids) {
dataRef.clear();
_.map(ids, (id) => {
dataRef.add(id);
});
dataRef.onUpdate(ids);
}

Просмотреть файл

@ -0,0 +1,62 @@
import { Subject } from '@bingads-webui-universal/observer-pattern';
import _ from 'underscore';
import { Record } from './record';
import { OVERREACT_ID_FIELD_NAME } from './consts';
export class RecordGroup extends Subject {
constructor(schemaType) {
super();
this.schemaType = schemaType;
// records will be kept in chronological order
// new records will always be kept at the end of the list
this.records = [];
this.addOrUpdateRecords = this.addOrUpdateRecords.bind(this);
this.getRecords = this.getRecords.bind(this);
this.getRecordsByEntityKeys = this.getRecordsByEntityKeys.bind(this);
}
findIndex(dataId) {
return this.records.findIndex(r => r.id === dataId);
}
addOrUpdateRecordInternal(data) {
const dataId = data[OVERREACT_ID_FIELD_NAME];
const recordId = this.findIndex(dataId);
if (recordId > -1) {
// we're updating
this.records[recordId].setData(data);
} else {
// "add" record - because we'll be appending
const newRecord = new Record(dataId, this.schemaType, data);
this.records.push(newRecord);
}
return dataId;
}
addOrUpdateRecords(dataItems, request) {
const updatedDataIDs = dataItems.map(data => this.addOrUpdateRecordInternal(data));
this.notify('dataRefIdsUpdate', updatedDataIDs, request);
}
deleteRecords(ids) {
this.records = this.records.filter(record => !_.contains(ids, record.id));
}
getRecords(ids) {
return ids.map(id =>
this.records.find(r => r.id === id));
}
getRecordsByEntityKeys(keySelector, keys) {
return _.chain(keys)
.map(key => this.records.find(r => keySelector(r.getData()) === key))
.compact()
.value();
}
}

Просмотреть файл

@ -0,0 +1,34 @@
export class Record {
constructor(id, type, data) {
this.id = id;
this.type = type;
this.data = data;
this.setValue = this.setValue.bind(this);
this.getValue = this.getValue.bind(this);
this.setData = this.setData.bind(this);
this.getData = this.getData.bind(this);
}
setValue(key, value) {
this.data = {
...this.data,
[key]: value,
};
}
getValue(key) {
return this.data[key];
}
setData(data) {
this.data = {
...this.data,
...data,
};
}
getData() {
return this.data;
}
}

Просмотреть файл

@ -0,0 +1,36 @@
import { DataRef } from './data-ref';
// this will be attached to the schema node as an extension
// and represent all data stored under the data path given by the schema node
// each data node contains an array of data IDs
// which refers to the actual data record in the store.
export class DataNode {
constructor() {
this.dataRefs = {};
this.getDataRef = this.getDataRef.bind(this);
this.dataRefIdsUpdate = this.dataRefIdsUpdate.bind(this);
this.entitiesCreated = this.entitiesCreated.bind(this);
this.entitiesDeleted = this.entitiesDeleted.bind(this);
}
getDataRef(key) {
if (!this.dataRefs[key]) {
this.dataRefs[key] = new DataRef(key);
}
return this.dataRefs[key];
}
dataRefIdsUpdate(recordGroup, updatedIds, request) {
Object.keys(this.dataRefs).forEach(key => this.dataRefs[key].onUpdate(updatedIds, request));
}
entitiesCreated(recordGroup, ids) {
Object.keys(this.dataRefs).forEach(key => this.dataRefs[key].onEntitiesCreated(ids));
}
entitiesDeleted(recordGroup, ids) {
Object.keys(this.dataRefs).forEach(key => this.dataRefs[key].onEntitiesDeleted(ids));
}
}

Просмотреть файл

@ -0,0 +1,101 @@
import { Subject } from '@bingads-webui-universal/observer-pattern';
import _ from 'underscore';
export class DataRef extends Subject {
constructor(key) {
super();
this.key = key;
this.idRefs = [];
this.status = {
previousVariables: undefined,
lastUpdateTimestamp: Date.now(),
error: undefined,
};
this.includes = this.includes.bind(this);
this.push = this.push.bind(this);
this.add = this.add.bind(this);
this.delete = this.delete.bind(this);
this.onUpdate = this.onUpdate.bind(this);
this.updateStatus = this.updateStatus.bind(this);
this.onError = this.onError.bind(this);
this.clearError = this.clearError.bind(this);
this.onEntitiesCreated = this.onEntitiesCreated.bind(this);
this.onEntitiesDeleted = this.onEntitiesDeleted.bind(this);
this.reset = this.reset.bind(this);
// TODO: use "cursor" for specific pagination needs
this.cursor = {};
}
includes(id) {
return this.idRefs.includes(id);
}
push(id) {
const ret = this.idRefs.push(id);
return ret;
}
add(id) {
// add/merge an id. If id exists in idRefs, do nothing
if (this.includes(id)) {
return;
}
this.push(id);
}
delete(ids) {
if (_.intersection(this.idRefs, ids).length > 0) {
this.idRefs = _.difference(this.idRefs, ids);
}
}
onError(error, ...args) {
this.status.error = error;
this.notify('onError', error, ...args);
}
clearError() {
this.onError(undefined);
}
// currently, we will always add something to this data ref after clear, and trigger event then
// so here we don't trigger a notification anymore: this.notify('update', []);
// otherwise UI component will get a incorrect empty status for short time
clear() {
this.idRefs = [];
this.clearError();
}
reset(ids) {
this.idRefs = ids;
this.notify('update', ids);
}
updateStatus(newStatus) {
this.status = {
...this.status,
...newStatus,
};
}
onUpdate(updatedIds, request) {
if (_.intersection(this.idRefs, updatedIds).length > 0) {
this.notify('update', updatedIds, request);
}
}
onEntitiesCreated(ids) {
this.notify('onEntitiesCreated', ids);
}
onEntitiesDeleted(ids) {
if (_.intersection(this.idRefs, ids).length > 0) {
this.delete(ids);
this.notify('update', this.idRefs);
}
}
}

Просмотреть файл

@ -0,0 +1,24 @@
import { DataNode } from './data-node';
const EXTENSION_NAME = 'DATA';
export function createDataNode(schemaNode, store) {
if (!schemaNode) {
throw new Error('Invalid schema node');
}
const dataNode = new DataNode();
store.getRecordGroup(schemaNode.modelName).subscribe(dataNode);
schemaNode.setExtension(EXTENSION_NAME, dataNode);
return dataNode;
}
export function getDataNode(schemaNode) {
if (!schemaNode) {
return null;
}
return schemaNode.getExtension(EXTENSION_NAME);
}

Просмотреть файл

@ -0,0 +1,30 @@
/* global Map */
// Use the basic APIs of ES6 Map. If your site supports browsers not
// having Map natively, you should use a polyfill
import { RecordGroup } from './record-group';
export class Store {
constructor() {
this.recordGroups = {};
this.preemptiveRecords = new Map();
this.getRecordGroup = this.getRecordGroup.bind(this);
}
getRecordGroup(modelName) {
if (!this.recordGroups[modelName]) {
this.recordGroups[modelName] = new RecordGroup(modelName);
}
return this.recordGroups[modelName];
}
addPreemptiveRecords(key, records) {
this.preemptiveRecords.set(key, records);
}
removePreemptiveRecords(key) {
const records = this.preemptiveRecords.get(key);
this.preemptiveRecords.delete(key);
return records;
}
}