import { Collection , ComponentRequestTable , ConceptDb , ConceptDbNotFoundError , ConceptServer , ConceptServerBuilder , Config , Context , EMPTY_CONTEXT , getReturnFields } from '@deja-vu/concept-server' ; import { readFileSync } from 'fs' ; import { IResolvers } from 'graphql-tools' ; import * as _ from 'lodash' ; import * as path from 'path' ; import { v4 as uuid } from 'uuid' ; import { AccountDoc , AddToBalanceInput , CreateTransferInput , ItemCount , TransferDoc , TransfersInput } from './schema' ; interface TransferConfig extends Config { balanceType : 'money' | 'items' ; } const componentRequestTable = { 'add-to-balance' : ( extraInfo ) => `
    mutation AddToBalance($input: AddToBalanceInput!) {
      addToBalance(input: $input) ${ getReturnFields ( extraInfo ) }
    }
  ` , 'create-transfer' : ( extraInfo ) => `
    mutation CreateTransfer($input: CreateTransferInput!) {
      createTransfer(input: $input) ${ getReturnFields ( extraInfo ) }
    }
  ` , 'show-balance' : ( extraInfo ) => `
    query ShowBalance($accountId: ID!) {
      balance(accountId: $accountId) ${ getReturnFields ( extraInfo ) }
    }
  ` , 'show-transfer' : ( extraInfo ) => `
    query ShowTransfer($id: ID!) {
      transfer(id: $id) ${ getReturnFields ( extraInfo ) }
    }
  ` , 'show-transfers' : ( extraInfo ) => `
    query ShowTransfers($input: TransfersInput!) {
      transfers(input: $input) ${ getReturnFields ( extraInfo ) }
    }
  ` } ; type AccountHasFundsFn < Balance > = ( accountBalance : Balance , transferAmount : Balance ) => boolean ; type NewBalanceFn < Balance > = ( accountBalance : Balance , transferAmount : Balance ) => Balance ; type NegateBalanceFn < Balance > = ( balance : Balance ) => Balance ; type ZeroBalanceFn < Balance > = ( ) => Balance ; type IsZeroBalanceFn < Balance > = ( balance : Balance ) => boolean ; function getResolvers < Balance > ( db , accounts , transfers , accountHasFundsFn , newBalanceFn , negateBalanceFn , zeroBalanceFn , isZeroBalanceFn ) { return { Query : { balance : async ( _root , { accountId } ) => { try { const account = await accounts . findOne ( { id : accountId } ) ; return account . balance ; } catch ( err ) { if ( err . errorCode !== ConceptDbNotFoundError . ERROR_CODE ) { throw err ; } return zeroBalanceFn ( ) ; } } , transfers : async ( _root , { input } ) => { return await transfers . find ( input ) ; } , transfer : async ( _root , { id } ) => { return await transfers . findOne ( { id : id } ) ; } } , Mutation : { addToBalance : async ( _root , { input } , context ) : Promise < TransferDoc < Balance > > => { const transfer = { id : uuid ( ) , toId : input . accountId , amount : input . amount } ; await addToBalance < Balance > ( db , context , accounts , transfers , input . accountId , input . amount , transfer , true , accountHasFundsFn , newBalanceFn , zeroBalanceFn , isZeroBalanceFn ) ; return transfer ; } , createTransfer : async ( _root , { input } , context ) : Promise < TransferDoc < Balance > > => { const transfer = { id : input . id ? input . id : uuid ( ) , fromId : input . fromId , toId : input . toId , amount : input . amount } ; transfer . fromId = input . fromId ; return await db . inTransaction ( async ( ) => { await addToBalance < Balance > ( db , context , accounts , transfers , input . fromId , negateBalanceFn ( input . amount ) , transfer , true , accountHasFundsFn , newBalanceFn , zeroBalanceFn , isZeroBalanceFn ) ; await addToBalance < Balance > ( db , context , accounts , transfers , input . toId , input . amount , transfer , false , accountHasFundsFn , newBalanceFn , zeroBalanceFn , isZeroBalanceFn ) ; return transfer ; } ) ; } } } ; } async function addToBalanceOperation < Balance > ( context , accounts , accountId , amount , accountHasFundsFn , newBalanceFn , zeroBalanceFn , isZeroBalanceFn ) { return await accounts . findOneAndUpdateWithFn ( context , { id : accountId } , ( fetchedAccount ) => { const newBalance = newBalanceFn ( fetchedAccount . balance , amount ) ; if ( isZeroBalanceFn ( newBalance ) ) { return undefined ; } return { $set : { balance : newBalance } } ; } , { upsert : true , setOnInsert : { balance : zeroBalanceFn ( ) } } , ( fetchedAccount ) => { if ( ! accountHasFundsFn ( fetchedAccount . balance , amount ) ) { throw new Error ( `Account ${ accountId } has insufficient funds` ) ; } } ) ; } param context param accounts param transfers param accountId param amount param transfer param onCommitAddTransferToLog param accountHasFundsFn param newBalanceFn param zeroBalanceFn param isZeroBalanceFn throws async function addToBalance < Balance > ( db , context , accounts , transfers , accountId , amount , transfer , onCommitAddTransferToLog , accountHasFundsFn , newBalanceFn , zeroBalanceFn , isZeroBalanceFn ) { if ( _ . isEmpty ( accountId ) ) { throw new Error ( `Invalid account id ${ accountId }` ) ; } console . log ( `Adding ${ JSON . stringify ( amount ) } to ${ accountId }` ) ; if ( ( context . reqType === undefined || context . reqType === 'commit' ) && onCommitAddTransferToLog ) { return db . inTransaction ( async ( ) => { const account = await addToBalanceOperation < Balance > ( context , accounts , accountId , amount , accountHasFundsFn , newBalanceFn , zeroBalanceFn , isZeroBalanceFn ) ; await transfers . insertOne ( EMPTY_CONTEXT , transfer ) ; return account ; } ) ; } return await addToBalanceOperation < Balance > ( context , accounts , accountId , amount , accountHasFundsFn , newBalanceFn , zeroBalanceFn , isZeroBalanceFn ) ; } function getDynamicTypeDefs ( config ) { const schemaTemplatePath = path . join ( __dirname , 'schema.template.graphql' ) ; const schemaTemplate = readFileSync ( schemaTemplatePath , 'utf8' ) ; if ( config . balanceType === 'money' ) { return [ _ . replace ( _ . replace ( schemaTemplate , /#BalanceInput/g , 'Float' ) , /#Balance/g , 'Float' ) ] ; } else { return [ _ . replace ( _ . replace ( schemaTemplate . concat ( `
              input ItemCountInput {
                id: ID!
                count: Int
              }

              type ItemCount {
                id: ID!
                count: Int
              }
            ` ) , /#BalanceInput/g , '[ItemCountInput]' ) , /#Balance/g , '[ItemCount]' ) ] ; } } function resolvers ( db , config ) { if ( config . balanceType === 'money' ) { const accounts = db . collection ( 'accounts' ) ; const transfers = db . collection ( 'transfers' ) ; const newBalanceFn = ( accountBalance , transferAmount ) => ( accountBalance + transferAmount ) ; const accountHasFundsFn = ( accountBalance , transferAmount ) => ( newBalanceFn ( accountBalance , transferAmount ) >= 0 ) ; const negateBalanceFn = ( balance ) => - balance ; const zeroBalanceFn = ( ) => 0 ; const isZeroBalanceFn = ( balance ) => balance === 0 ; return getResolvers < number > ( db , accounts , transfers , accountHasFundsFn , newBalanceFn , negateBalanceFn , zeroBalanceFn , isZeroBalanceFn ) ; } else { const accounts = db . collection ( 'accounts' ) ; const transfers = db . collection ( 'transfers' ) ; const newBalanceFn = ( accountBalance , transferAmount ) => { const accountBalanceMap = _ . reduce ( accountBalance , ( acc , itemCount ) => { acc [ itemCount . id ] = itemCount . count ; return acc ; } , { } ) ; _ . each ( transferAmount , ( itemCount ) => { const prevItemCount = _ . get ( accountBalanceMap , itemCount . id , 0 ) ; const newCount = prevItemCount + itemCount . count ; if ( newCount === 0 ) { delete accountBalanceMap [ itemCount . id ] ; } else { accountBalanceMap [ itemCount . id ] = newCount ; } } ) ; return _ . map ( accountBalanceMap , ( value , key ) : ItemCount => { return { id : key , count : value } ; } ) ; } ; const accountHasFundsFn = ( accountBalance , transferAmount ) => { const newBalance = newBalanceFn ( accountBalance , transferAmount ) ; for ( const itemCount of newBalance ) { if ( itemCount . count < 0 ) { return false ; } } return true ; } ; const negateBalanceFn = ( balance ) => { return _ . map ( balance , ( itemCount ) => { return { id : itemCount . id , count : - itemCount . count } ; } ) ; } ; const zeroBalanceFn = ( ) => [ ] ; const isZeroBalanceFn = ( balance ) => _ . isEmpty ( balance ) ; const baseResolvers = getResolvers < ItemCount [ ] > ( db , accounts , transfers , accountHasFundsFn , newBalanceFn , negateBalanceFn , zeroBalanceFn , isZeroBalanceFn ) ; return _ . merge ( baseResolvers , { ItemCount : { id : ( itemCount ) => itemCount . id , count : ( itemCount ) => itemCount . count } } ) ; } } const transferConcept = new ConceptServerBuilder ( 'transfer' ) . initDb ( ( db , _config ) : Promise < any > => { /**
     * `transfers` is the main collection,
     * the only reason why we have an `accounts`
     * collection is to keep track of the balance (to avoid having to compute it
     * every time) and to be able to lock accounts when processing transfers.
     */ const accounts = db . collection ( 'accounts' ) ; const transfers = db . collection ( 'transfers' ) ; return Promise . all ( [ accounts . createIndex ( { id : 1 } , { unique : true , sparse : true } ) , transfers . createIndex ( { id : 1 } , { unique : true , sparse : true } ) , transfers . createIndex ( { fromId : 1 } , { sparse : true } ) , transfers . createIndex ( { toId : 1 } , { sparse : true } ) ] ) ; } ) . componentRequestTable ( componentRequestTable ) . resolvers ( resolvers ) . dynamicTypeDefs ( getDynamicTypeDefs ) . build ( ) ; transferConcept . start ( ) ;