Achieve real multitenancy with Hasura

Hi all! I am building a multi-shop ecommerce website, and I have my database design as follows:

Schema | Table | Description
demo_store | products | The products of the demo store
windows_store | products | The products of the windows store
doors_store | products | The products of the doors store

To get the products of the demo_store, I am doing this query:

const GET_PRODUCT = gql`
  query GetProducts($id: bigint!) {
    result: demo_store_products(where: {id: {_eq: $id}}) {
      id
      sku
      translations {
        attribute  
        language
        translation
      }
    }
  }
`;

As I want to have one query for all stores, instead of having to define 20 queries for 20 stores, I am doing this in my client.ts

const multiTenancy = new ApolloLink((operation, forward) => {
  operation.query = cloneDeep(operation.query);
  const { headers = {} } = operation.getContext();
  console.log("Headers: ", headers);
  getNavigationBy(
    operation.query.definitions,
    "selectionSet.selections",
    "query.definitions"
  ).forEach((s) => {
    const node: SelectionNode | undefined = resolveNode<SelectionNode>(s, operation);
    if (node && node.kind === Kind.FIELD && headers["store"]) {
      // @ts-ignore the value is read only, but it has to be overwritten
      node.name.value = node.name.value.replace("demo_store", headers["store"]);
    }
  });
  return forward(operation);
});

const httpLink = new HttpLink({
  uri: getGQLURL(),
  fetch,
  headers: {
    "x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET ?? "",
  },
});

export const client = new ApolloClient({
  link: from([multiTenancy, httpLink]),
  cache: new InMemoryCache(),
  defaultOptions,
});

This code intercepts the query, and if it is executed against demo_store, and there is a header “store”, it changes the demo_store to the value in the header. For example, if I submit a query like the one above with a header=windows_store, I will see the products of the windows_store. The main idea is that I want to keep the type safety and still use only one query for multi-tenant tables.

My problem:
This works so far, because I do not have cache turned on:

const defaultOptions: DefaultOptions = {
  watchQuery: {
    fetchPolicy: "no-cache",
    errorPolicy: "ignore",
  },
  query: {
    fetchPolicy: "no-cache",
    errorPolicy: "all",
  },
  mutate: {
    errorPolicy: "all",
  },
};

If I turn on the cache and execute this against demo_store, the cache is populated with:

{
  "demo_store_products:1": {
    "__typename": "demo_store_products",
    "id": 1,
    "sku": "demo_test",
    "translations": []
  },
  "ROOT_QUERY": {
    "__typename": "Query",
    "demo_store_products({\"where\":{\"id\":{\"_eq\":1}}})": [
      {
        "__ref": "demo_store_products:1"
      }
    ]
  }
}

Then I execute the same, but with header store=windows_store, I get the exact same result from the cache (response is 4ms)

{
    "success": true,
    "message": "ok",
    "result": {
        "data": {
            "result": [
                {
                    "__typename": "demo_store_products",
                    "id": 1,
                    "sku": "demo_test",
                    "translations": []
                }
            ]
        },
        "loading": false,
        "networkStatus": 7
    }
}

If I restart the server and execute the query against another store (with headers=windows_store) first:
Response:

{
    "success": true,
    "message": "ok",
    "result": {
        "data": {
            "result": [
                {
                    "__typename": "windows_store_products",
                    "id": 1,
                    "sku": "windows_store_test",
                    "translations": []
                }
            ]
        },
        "loading": false,
        "networkStatus": 7
    }
}

Cache:

{
  "windows_store:1": {
    "__typename": "windows_store_products",
    "id": 1,
    "sku": "windows_store_test",
    "translations": []
  },
  "ROOT_QUERY": {
    "__typename": "Query",
    "demo_store_products({\"where\":{\"id\":{\"_eq\":1}}})": [
      {
        "__ref": "windows_store_products:1"
      }
    ]
  }
}

Any ideas how I can achieve my desired goal - have the ability to use one query, and change the tenant I execute on, and keep the type safety?

Thanks in advance!

Hi @vmvelev :wave: welcome to the forum! I think I know what’s causing that issue you’re seeing. Apollo Client is really good at normalizing cached data given field names, __typenames, ids, variables, etc. So in your app, the field demo_store_products will be stored by that name in the cache. As far as the client knows, that’s the only field on your root query.

When you transform that field name in the Apollo Link chain, however, you’re asking the server for a field with a name that’s different from the query the client thinks it’s requesting. In this case, it’s important that you can differentiate each query in a way that you can communicate to the cache. Maybe you could consider using a query argument? That way you could pass in the dynamic value for store without much duplication.

For what it’s worth, this architecture seems like it might lead to more difficulties down the road. I don’t know much about Hasura or what you’re trying to accomplish, so I want to acknowledge my opinion probably isn’t worth much here. Best of luck to you :pray:t2: Let me know if there’s anything I can clarify further here!

I’ll also add that modifications to the query in the link chain aren’t seen by the cache, so this might also be part of the problem. The cache thinks you’re storing data using the original query, not your modified query with the updated field name, hence the mismatch.

As @JeffAuriemma, you might consider rethinking this a bit as this might lead to some weird bugs and difficulties.

Hey @JeffAuriemma and @jerelmiller! Thank you for responding, much appreciated.

You are indeed correct, and going that road is really dangerous, so we decided that we will clear the cache as soon as the store is changed. This would have affected only a small percent of our users. We think that they won’t even notice.

To all the guys that are struggling with the same - my advice to you is “don’t go down that road, it’s dangerous”.

We will keep the current solution of changing the query in a link, as we do not want to write queries and mutations for each and every tenant we have, but we will clear the cache after changing the store.

You can consider this as resolved :slight_smile:

1 Like