mirror of
https://github.com/cerc-io/watcher-ts
synced 2025-09-19 04:54:07 +00:00
* Move graph-database from graph-node to util * Refactor and remove graph-node dependency from cli package * Modify dependencies using depcheck * Implement CLI refactoring changes in other watchers * Review changes to remove eden comment and fix local import in util * Import GraphDatabase from util instead of graph-node * Move graph-node non assemblyscript code to util package * Implement CLI refactoring changes in codegen * Fix graph-node tests after refactoring * Move fromStateEntityValues to graph state utils
395 lines
10 KiB
TypeScript
395 lines
10 KiB
TypeScript
//
|
|
// Copyright 2022 Vulcanize, Inc.
|
|
//
|
|
|
|
import assert from 'assert';
|
|
import util from 'util';
|
|
import path from 'path';
|
|
import toml from 'toml';
|
|
import fs from 'fs-extra';
|
|
import { diffString, diff } from 'json-diff';
|
|
import _ from 'lodash';
|
|
import omitDeep from 'omit-deep';
|
|
import debug from 'debug';
|
|
|
|
import { Config as CacheConfig, getCache } from '@cerc-io/cache';
|
|
import { GraphQLClient } from '@cerc-io/ipld-eth-client';
|
|
import { gql } from '@apollo/client/core';
|
|
import { DEFAULT_LIMIT } from '@cerc-io/util';
|
|
|
|
import { Client } from './client';
|
|
|
|
const log = debug('vulcanize:compare-utils');
|
|
|
|
const STATE_QUERY = `
|
|
query getState($blockHash: String!, $contractAddress: String!, $kind: String){
|
|
getState(blockHash: $blockHash, contractAddress: $contractAddress, kind: $kind){
|
|
block {
|
|
cid
|
|
number
|
|
hash
|
|
}
|
|
contractAddress
|
|
cid
|
|
kind
|
|
data
|
|
}
|
|
}
|
|
`;
|
|
|
|
interface EndpointConfig {
|
|
gqlEndpoint1: string;
|
|
gqlEndpoint2: string;
|
|
requestDelayInMs: number;
|
|
}
|
|
|
|
interface QueryConfig {
|
|
queryDir: string;
|
|
names: { [queryName: string]: string };
|
|
blockDelayInMs: number;
|
|
queryLimits: { [queryName: string]: number }
|
|
}
|
|
|
|
interface EntitySkipFields {
|
|
entity: string;
|
|
fields: string[];
|
|
}
|
|
|
|
export interface Config {
|
|
endpoints: EndpointConfig;
|
|
queries: QueryConfig;
|
|
watcher: {
|
|
configPath: string;
|
|
entitiesDir: string;
|
|
verifyState: boolean;
|
|
endpoint: keyof EndpointConfig;
|
|
skipFields: EntitySkipFields[];
|
|
contracts: string[];
|
|
}
|
|
cache: {
|
|
endpoint: keyof EndpointConfig;
|
|
config: CacheConfig;
|
|
}
|
|
}
|
|
|
|
export const getConfig = async (configFile: string): Promise<Config> => {
|
|
const configFilePath = path.resolve(configFile);
|
|
const fileExists = await fs.pathExists(configFilePath);
|
|
|
|
if (!fileExists) {
|
|
throw new Error(`Config file not found: ${configFilePath}`);
|
|
}
|
|
|
|
const config = toml.parse(await fs.readFile(configFilePath, 'utf8'));
|
|
|
|
if (config.queries.queryDir) {
|
|
// Resolve path from config file path.
|
|
const configFileDir = path.dirname(configFilePath);
|
|
config.queries.queryDir = path.resolve(configFileDir, config.queries.queryDir);
|
|
}
|
|
|
|
return config;
|
|
};
|
|
|
|
interface CompareResult {
|
|
diff: string,
|
|
result1: any,
|
|
result2: any
|
|
}
|
|
|
|
export const compareQuery = async (
|
|
clients: {
|
|
client1: Client,
|
|
client2: Client
|
|
},
|
|
queryName: string,
|
|
params: { [key: string]: any },
|
|
rawJson: boolean,
|
|
timeDiff: boolean
|
|
): Promise<CompareResult> => {
|
|
const { client1, client2 } = clients;
|
|
|
|
const [
|
|
{ data: result1, time: time1 },
|
|
{ data: result2, time: time2 }
|
|
] = await Promise.all([
|
|
client1.getResult(queryName, params),
|
|
client2.getResult(queryName, params)
|
|
]);
|
|
|
|
if (timeDiff) {
|
|
log(`time:utils#compareQuery-${queryName}-${JSON.stringify(params)}-gql1-[${time1}ms]-gql2-[${time2}ms]-diff-[${time1 - time2}ms]`);
|
|
}
|
|
|
|
// Getting the diff of two result objects.
|
|
const resultDiff = compareObjects(result1, result2, rawJson);
|
|
|
|
return {
|
|
diff: resultDiff,
|
|
result1,
|
|
result2
|
|
};
|
|
};
|
|
|
|
export const getClients = async (config: Config, timeDiff: boolean, queryDir?: string):Promise<{
|
|
client1: Client,
|
|
client2: Client
|
|
}> => {
|
|
assert(config.endpoints, 'Missing endpoints config');
|
|
|
|
const {
|
|
endpoints: { gqlEndpoint1, gqlEndpoint2 },
|
|
cache: { endpoint, config: cacheConfig }
|
|
} = config;
|
|
|
|
assert(gqlEndpoint1, 'Missing endpoint one');
|
|
assert(gqlEndpoint2, 'Missing endpoint two');
|
|
|
|
if (!queryDir) {
|
|
assert(config.queries, 'Missing queries config');
|
|
queryDir = config.queries.queryDir;
|
|
}
|
|
|
|
assert(queryDir, 'Query directory not provided');
|
|
assert(cacheConfig, 'Cache config not provided');
|
|
const cache = await getCache(cacheConfig);
|
|
|
|
const client1 = new Client({
|
|
gqlEndpoint: gqlEndpoint1,
|
|
cache: endpoint === 'gqlEndpoint1' ? cache : undefined
|
|
}, timeDiff, queryDir);
|
|
|
|
const client2 = new Client({
|
|
gqlEndpoint: gqlEndpoint2,
|
|
cache: endpoint === 'gqlEndpoint2' ? cache : undefined
|
|
}, timeDiff, queryDir);
|
|
|
|
return {
|
|
client1,
|
|
client2
|
|
};
|
|
};
|
|
|
|
export const getStatesByBlock = async (client: GraphQLClient, contracts: string[], blockHash: string): Promise<{[key: string]: any}[][]> => {
|
|
// Fetch States for all contracts
|
|
return Promise.all(contracts.map(async contract => {
|
|
const { getState } = await client.query(
|
|
gql(STATE_QUERY),
|
|
{
|
|
blockHash,
|
|
contractAddress: contract
|
|
}
|
|
);
|
|
|
|
const states = [];
|
|
|
|
// If 'checkpoint' is found at the same block, fetch 'diff' as well
|
|
if (getState && getState.kind === 'checkpoint' && getState.block.hash === blockHash) {
|
|
// Check if 'init' present at the same block
|
|
const { getState: getInitState } = await client.query(
|
|
gql(STATE_QUERY),
|
|
{
|
|
blockHash,
|
|
contractAddress: contract,
|
|
kind: 'init'
|
|
}
|
|
);
|
|
|
|
if (getInitState && getInitState.block.hash === blockHash) {
|
|
// Append the 'init' state to the result
|
|
states.push(getInitState);
|
|
}
|
|
|
|
// Check if 'diff' state present at the same block
|
|
const { getState: getDiffState } = await client.query(
|
|
gql(STATE_QUERY),
|
|
{
|
|
blockHash,
|
|
contractAddress: contract,
|
|
kind: 'diff'
|
|
}
|
|
);
|
|
|
|
if (getDiffState && getDiffState.block.hash === blockHash) {
|
|
// Append the 'diff' state to the result
|
|
states.push(getDiffState);
|
|
}
|
|
}
|
|
|
|
// Append the state to the result
|
|
states.push(getState);
|
|
|
|
return states;
|
|
}));
|
|
};
|
|
|
|
export const checkStateMetaData = (contractState: {[key: string]: any}, contractLatestStateCIDMap: Map<string, { diff: string, checkpoint: string }>, rawJson: boolean) => {
|
|
// Return if State for a contract not found
|
|
if (!contractState) {
|
|
return;
|
|
}
|
|
|
|
const { contractAddress, cid, kind, block } = contractState;
|
|
|
|
const parentCIDs = contractLatestStateCIDMap.get(contractAddress);
|
|
assert(parentCIDs);
|
|
|
|
// If CID is same as the parent CID, skip the check
|
|
if (cid === parentCIDs.diff || cid === parentCIDs.checkpoint) {
|
|
return;
|
|
}
|
|
|
|
// Update the parent CIDs in the map
|
|
// Keep previous 'diff' if kind is 'checkpoint'
|
|
const nextParentCIDs = (kind === 'checkpoint')
|
|
? { diff: parentCIDs.diff, checkpoint: cid as string }
|
|
: { diff: cid, checkpoint: '' };
|
|
contractLatestStateCIDMap.set(contractAddress, nextParentCIDs);
|
|
|
|
// Actual meta data from the GQL result
|
|
const data = JSON.parse(contractState.data);
|
|
|
|
// If parentCID not initialized (is empty at start)
|
|
// Take the expected parentCID from the actual data itself
|
|
let parentCID: string;
|
|
const actualParentCID = data.meta.parent['/'];
|
|
if (parentCIDs.diff === '') {
|
|
parentCID = actualParentCID;
|
|
} else {
|
|
// Check if actual parent CID points to previous 'checkpoint'
|
|
parentCID = (parentCIDs.checkpoint !== '' && actualParentCID === parentCIDs.checkpoint)
|
|
? parentCIDs.checkpoint
|
|
: parentCIDs.diff;
|
|
}
|
|
|
|
// Expected meta data
|
|
const expectedMetaData = {
|
|
id: contractAddress,
|
|
kind,
|
|
parent: {
|
|
'/': parentCID
|
|
},
|
|
ethBlock: {
|
|
cid: {
|
|
'/': block.cid
|
|
},
|
|
num: block.number
|
|
}
|
|
};
|
|
|
|
return compareObjects(expectedMetaData, data.meta, rawJson);
|
|
};
|
|
|
|
export const combineState = (contractStateEntries: {[key: string]: any}[]): {[key: string]: any} => {
|
|
const contractStates: {[key: string]: any}[] = contractStateEntries.map(contractStateEntry => {
|
|
if (!contractStateEntry) {
|
|
return {};
|
|
}
|
|
|
|
const data = JSON.parse(contractStateEntry.data);
|
|
|
|
// Apply default limit and sort by id on array type relation fields.
|
|
Object.values(data.state)
|
|
.forEach((idEntityMap: any) => {
|
|
Object.values(idEntityMap)
|
|
.forEach((entity: any) => {
|
|
Object.values(entity)
|
|
.forEach(fieldValue => {
|
|
if (
|
|
Array.isArray(fieldValue) &&
|
|
fieldValue.length &&
|
|
fieldValue[0].id
|
|
) {
|
|
fieldValue.sort((a: any, b: any) => a.id.localeCompare(b.id));
|
|
fieldValue.splice(DEFAULT_LIMIT);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
return data.state;
|
|
});
|
|
|
|
return contractStates.reduce((acc, state) => _.merge(acc, state));
|
|
};
|
|
|
|
export const checkGQLEntityInState = async (
|
|
state: {[key: string]: any},
|
|
entityName: string,
|
|
entityResult: {[key: string]: any},
|
|
id: string,
|
|
rawJson: boolean,
|
|
skipFields: EntitySkipFields[] = []
|
|
): Promise<string> => {
|
|
const stateEntity = state[entityName][id];
|
|
|
|
// Filter __typename key in GQL result.
|
|
entityResult = omitDeep(entityResult, '__typename');
|
|
|
|
// Filter skipped fields in state comaparison.
|
|
skipFields.forEach(({ entity, fields }) => {
|
|
if (entityName === entity) {
|
|
omitDeep(entityResult, fields);
|
|
omitDeep(stateEntity, fields);
|
|
}
|
|
});
|
|
|
|
const diff = compareObjects(entityResult, stateEntity, rawJson);
|
|
|
|
return diff;
|
|
};
|
|
|
|
export const checkGQLEntitiesInState = async (
|
|
state: {[key: string]: any},
|
|
entityName: string,
|
|
entitiesResult: any[],
|
|
rawJson: boolean,
|
|
skipFields: EntitySkipFields[] = []
|
|
): Promise<string> => {
|
|
// Form entities from state to compare with GQL result
|
|
const stateEntities = state[entityName];
|
|
|
|
for (const entityResult of entitiesResult) {
|
|
const stateEntity = stateEntities[entityResult.id];
|
|
|
|
// Verify state if entity from GQL result is present in state.
|
|
if (stateEntity) {
|
|
// Filter __typename key in GQL result.
|
|
entitiesResult = omitDeep(entityResult, '__typename');
|
|
|
|
// Filter skipped fields in state comaparison.
|
|
skipFields.forEach(({ entity, fields }) => {
|
|
if (entityName === entity) {
|
|
omitDeep(entityResult, fields);
|
|
omitDeep(stateEntity, fields);
|
|
}
|
|
});
|
|
|
|
const diff = compareObjects(entityResult, stateEntity, rawJson);
|
|
|
|
if (diff) {
|
|
return diff;
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
// obj1: expected
|
|
// obj2: actual
|
|
const compareObjects = (obj1: any, obj2: any, rawJson: boolean): string => {
|
|
if (rawJson) {
|
|
const diffObj = diff(obj1, obj2);
|
|
|
|
if (diffObj) {
|
|
// Use util.inspect to extend depth limit in the output.
|
|
return util.inspect(diffObj, false, null);
|
|
}
|
|
|
|
return '';
|
|
} else {
|
|
return diffString(obj1, obj2);
|
|
}
|
|
};
|