Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mutations #20

Merged
merged 5 commits into from
Feb 18, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions subgraph/mutations/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
package
dist
bundle
8 changes: 8 additions & 0 deletions subgraph/mutations/mutations.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
specVersion: 0.0.1
repository: ...
schema:
file: ./schema.graphql
resolvers:
kind: javascript
file: ./bundle/index.js
types: ./bundle/index.d.ts
33 changes: 33 additions & 0 deletions subgraph/mutations/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "example-mutations",
"version": "0.1.0",
"license": "MIT",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "yarn build:package && yarn build:bundle",
"build:bundle": "rimraf ./bundle && webpack",
"build:package": "tsc --outDir ./dist && rimraf ./package && mkdir ./package && cp -r ./dist ./package/dist && cp package.json ./package && cp -r ./src ./package/src",
"es5:check": "es-check es5 ./bundle/index.js"
},
"dependencies": {
"@graphprotocol/mutations": "^0.0.0-alpha.0",
"@types/node": "^13.7.1",
"ethers": "^4.0.40",
"graphql-tag": "^2.10.1",
"ipfs-http-client": "^42.0.0"
},
"devDependencies": {
"@babel/core": "^7.7.5",
"@babel/preset-env": "^7.7.6",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "^7.1.5",
"babel-plugin-add-module-exports": "^1.0.2",
"es-check": "^5.1.0",
"rimraf": "3.0.0",
"ts-loader": "6.2.1",
"typescript": "^3.7.3",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10"
}
}
1 change: 1 addition & 0 deletions subgraph/mutations/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# TODO: Schema
1 change: 1 addition & 0 deletions subgraph/mutations/src/__tests__/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
//TODO: tests
182 changes: 182 additions & 0 deletions subgraph/mutations/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import {
EventPayload,
MutationContext,
MutationResolvers,
MutationState,
StateBuilder,
StateUpdater,
} from "@graphprotocol/mutations"
import { ethers } from 'ethers'
import { Transaction } from 'ethers/utils'
import {
AsyncSendable,
Web3Provider
} from "ethers/providers"
import ipfsHttpClient from 'ipfs-http-client'

import {
sleep,
uploadToIpfs,
PROJECT_QUERY
} from './utils'
import { editProjectArgs, removeProjectArgs, addProjectArgs } from "./types"

// State Payloads + Events + StateBuilder
// TODO: Which custom events/reducers will we use? Will we use them at all?
interface CustomEvent extends EventPayload {
myValue: string
}

type EventMap = {
'CUSTOM_EVENT': CustomEvent
}

interface State {
myValue: string
myFlag: boolean
}

const stateBuilder: StateBuilder<State, EventMap> = {
getInitialState(): State {
return {
myValue: '',
myFlag: false
}
},
reducers: {
'CUSTOM_EVENT': async (state: MutationState<State>, payload: CustomEvent) => {
return {
myValue: 'true'
}
}
}
}

type Config = typeof config

const config = {
ethereum: (provider: AsyncSendable): Web3Provider => {
return new Web3Provider(provider)
},
ipfs: (endpoint: string) => {
const url = new URL(endpoint)
return ipfsHttpClient({
protocol: url.protocol.replace(/[:]+$/, ''),
host: url.hostname,
port: url.port,
'api-path': url.pathname.replace(/\/$/, '') + '/api/v0/',
})
}
}

type Context = MutationContext<Config, State, EventMap>

async function queryProject(context: Context, projectId: string) {
const { client } = context

if (client) {
for (let i = 0; i < 20; ++i) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we want to loop 20 times and query a project? @namesty

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its because of a bug we used to have in the spec, where the graph node wasn't updated fast enough, so at the time the query ran, the data was not still there

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nenadjaja this is because it's not guaranteed the graph-node we're querying has processed the block our "create project" transaction is in. We can solve this looping problem by using future queries, but I don't believe they're implemented yet. @Jannis ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a better way to loop. If we know the block that the transaction was mined in, we can query with

{
  project(id: ..., block: { hash: "..." }) {
    ...
  }
}

That will return an error if the block is not available yet. We can loop until it is available.

There is, of course, the chance, that the block never becomes available (e.g. when it never ends up on the canonical chain). For that, we could abort the loop after e.g. 30s or 60s and throw an error from the mutation. At that point we can be pretty certain that the transaction never truly made it to the chain, I think.

const { data } = await client.query({
query: PROJECT_QUERY,
variables: {
id: projectId,
}
}
)

if (data === null) {
await sleep(500)
} else {
return data.project
}
}
}

return null
}

async function sendTx(tx: Transaction, description: string, state: StateUpdater<State, EventMap>) {
try {
await state.dispatch('TRANSACTION_CREATED', {
id: tx.hash,
to: tx.to,
from: tx.from,
data: tx.data,
amount: tx.value.toString(),
network: `ethereum-${tx.chainId}`,
description
})
tx = await tx
await state.dispatch('TRANSACTION_COMPLETED', { id: tx.hash, description: tx.data })
return tx;
} catch (error) {
await state.dispatch('TRANSACTION_ERROR', error)
}
}

// TODO: Best way to get ABIs?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// TODO: Best way to get ABIs?

Good question. @davekaj do you have any ideas?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

../../../contracts/build/contracts/TokenRegistry.json').abi

or when lerna is activated

require('token-registry-contracts/build/contracts/TokenRegistry.json').abi,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, Ill make it that way, just like in the curation-starter

async function getContract(context: Context) { }

const uploadImage = async (_, { image }: any, context: Context) => {
const { ipfs } = context.graph.config

return await uploadToIpfs(ipfs, image)
}

const addProject = async (_, args: addProjectArgs, context: Context) => {

// const everest = await getContract(context)

// Dave's code goes here...
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! I will make a PR after we merge this one in, that adds the code

}

const removeProject = async (_, args: removeProjectArgs, context: Context) => {

const { projectId } = args

// const everest = await getContract(context)
// sendTx(everest.memberExit( ... ))

return true

}

const editProject = async (_, args: editProjectArgs, context: Context) => {

const { ipfs } = context.graph.config

const { id } = args

const metadata = Buffer.from( JSON.stringify( args ) )

const metadataHash = await uploadToIpfs(ipfs, metadata)

//TODO: are we sending metadata hash through state.dispatch?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe @nenadjaja knows

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@namesty we don't need that hash for anything else except for the contract call that you are making inside of that resolver. So I don't know if we need to dispatch it...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, great then :)


// const everest = await getContract(context)
// sendTx(everest.editOffChainDataSigned( ... ))

return await queryProject(context, id)
}

const resolvers: MutationResolvers<Config, State, EventMap> = {
Mutation: {
uploadImage,
addProject,
removeProject,
editProject
}
}

export default {
resolvers,
config,
stateBuilder
}

// Required Types
export {
State,
EventMap,
CustomEvent
}
31 changes: 31 additions & 0 deletions subgraph/mutations/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//TODO: Args are any by default in graphQL, do we really need these interfaces?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's good to have them so it's easier to see them. Maybe we can use the same args for add/edit project though (instead of repeating them)

export interface editProjectArgs{
id: string
name: string
description: string
avatar: string
image: string
website: string
github: string
twitter: string
isRepresentative: boolean
categories: Array<any>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be Any<string>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davekaj I think categories is an array of category objects. Basically we have a

type Category {
  id: ID!
  description: String!
}

type Project {
  id: ID!
  description: String!
  ...
  categories: [Category!]!
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we wanted an array of strings at the beginning, but then we realized that I need a Category entity and I need to pull all projects for a certain category, and all category info for a certain project. So we need to link them (many-to-many relationship)

}

export interface addProjectArgs{
id: string
name: string
description: string
avatar: string
image: string
website: string
github: string
twitter: string
isRepresentative: boolean
categories: Array<any>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be Any<string>

}

export interface removeProjectArgs{
projectId: string
}
39 changes: 39 additions & 0 deletions subgraph/mutations/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import gql from 'graphql-tag'

export const uploadToIpfs = async (ipfs: any, data: any): Promise<string> => {
let result

for await (const returnedValue of ipfs.add(data)) {
result = returnedValue
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if this errors out? How do we want to handle the error? 🤔

return result.path
}

export const sleep = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
}

export const PROJECT_QUERY = gql`
query everestProject($id: ID!) {
project(where: { id: $id }) {
id
name
description
categories
createdAt
reputation
isChallenged
website
twitter
github
image
avatar
totalVotes
owner {
id
name
}
}
}
`
15 changes: 15 additions & 0 deletions subgraph/mutations/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2015",
"moduleResolution": "node",
"declaration": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules/**/*"
]
}
43 changes: 43 additions & 0 deletions subgraph/mutations/webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const path = require("path");

module.exports = {
entry: {
app: "./src/index.ts"
},
output: {
filename: "index.js",
path: path.resolve(__dirname, "bundle"),
globalObject: "this",
libraryTarget: "commonjs"
},
target: "node",
mode: "development",
module: {
rules: [
// note that babel-loader is configured to run after ts-loader
{
test: /\.(ts)$/,
include: path.resolve(__dirname, "./src"),
use: [
{
loader: "babel-loader",
options: {
presets: [
["@babel/preset-env", { forceAllTransforms: true }]
],
plugins: ["add-module-exports"]
}
},
{
loader: "ts-loader",
options: {
compilerOptions: {
outDir: path.resolve(__dirname, "bundle")
}
}
}
]
}
]
}
};
Loading