awslabs / aws-mobile-appsync-sdk-js

JavaScript library files for Offline, Sync, Sigv4. includes support for React Native
Apache License 2.0
920 stars 266 forks source link

PLEASE HELP - How do I force AppSync client to re-hydrate itself #588

Open JohnAtFenestra opened 3 years ago

JohnAtFenestra commented 3 years ago

I am using AppSync client with Vue JS as a PWA. Because we are working with PII, I have created a storage layer which encrypts the data before storing in IndexedDB.

The issue I am running into is as follows:

When the PWA is run in offline mode, the user logs in and the system decrypts the store using a derived key from the password.

The problem is that AppSync has already hydrated itself and has stored an empty cache in its internal cache. Hence all queries fail to return any of the cached results.

I tried creating the client, however, I got an error

Error: The keyPrefix reduxPersist: is already in use. Multiple clients cannot share the same keyPrefix. Provide a different keyPrefix in the offlineConfig object. at new AWSAppSyncClient (client.js?def7:186)

This is a critical problem which unfortunately I just noticed and will threaten the project as offline mode is essential. I appreciate any input from anyone with an idea of how to have the system reinitialize its internal store from the actual store.

JohnAtFenestra commented 3 years ago

Adding a note that hopefully will be seen and answered by someone with internal knowledge. I attempted the brute force method of recreating the client when login occurs. However, that triggers a problem from keyPrefixesInUse

        keyPrefix = keyPrefix || DEFAULT_KEY_PREFIX;
        if (!disableOffline && keyPrefixesInUse.has(keyPrefix)) {
            throw new Error(`The keyPrefix ${keyPrefix} is already in use. Multiple clients cannot share the same keyPrefix. Provide a different keyPrefix in the offlineConfig object.`);
        }

I am beginning to think the only solution will be to take client.ts and incorporate a modified version into my own code and comment out this check. My question is, will recreating the client cause any other issues? Is there any better solution?

elorzafe commented 3 years ago

@JohnAtFenestra can you share a code snippet on how you are initializing the client?

JohnAtFenestra commented 3 years ago

A snippet would be difficult. Here are the three main parts. Note that in aws.js I have a commented line:

// import AWSAppSyncClient, { createAppSyncLink } from 'aws-appsync'

and a replacement line:

import AWSAppSyncClient, { createAppSyncLink } from 'src/vendor/aws-appsync/lib'

The active one is a newly complied copy of AppSync where I made a change which fixes the issue for my use case:

export type OfflineConfig = Pick<
  Partial<StoreOptions<any>>,
  'storage' | 'callback' | 'keyPrefix'
> & {
  storeCacheRootMutation?: boolean
  allowKeyPrefixReuse?: boolean
}
...
    keyPrefix = keyPrefix || DEFAULT_KEY_PREFIX
    if (
      !disableOffline &&
      keyPrefixesInUse.has(keyPrefix) &&
      !allowKeyPrefixReuse
    ) {
      throw new Error(
        `The keyPrefix ${keyPrefix} is already in use. Multiple clients cannot share the same keyPrefix. Provide a different keyPrefix in the offlineConfig object.`
      )
    }

In the boot section, I wrap the client in a proxy while exposes a method called $reinitialize. This call re-initializes the client and is invoked whenever a user signs in, after the encrypted store is unlocked. This allows the cache to be rehydrated from the decrypted data.

I spent several hours combing through the appsync sdk code and this was the only option which seemed to work. I decided to add a new boolean flag allowKeyPrefixReuse which would allow the change to be applied as a Pull Request if so desired.

src/boot/apolloClient.js

import { onlineListener } from 'src/lib/onlineManager'
import VueApollo from 'vue-apollo'
import AsyncComputed from 'vue-async-computed'
import { cryptoAdapterNew } from 'src/lib/cryptoAdapters'
import {
  createEncryptedStore,
  forageAdapterFactory
} from 'src/lib/localForageAdapter'
import { user } from 'src/lib/user'
import awsconfig from './config/local-awsconfiguration.json'
import apolloClientFactory from 'src/lib/apolloClient'
import localForage from 'localforage'
import { offlineManagerFactory } from 'src/lib/appSyncOfflineManagerFactory'

import axiosGraphQLFactory from 'src/lib/axiosGraphQL'

export default async ({ app, router, Vue }) => {
  const offlineManager = offlineManagerFactory()
  console.log({ offlineManager })

  const mockStorage = {
    signIn() {
      return new Promise((resolve, reject) => {
        resolve()
      })
    },
    signOut() {
      return new Promise((resolve, reject) => {
        resolve()
      })
    }
  }

  const config = {}
  const kind = 'offline-encrypted'
  switch (kind) {
    case 'offline-encrypted':
      config.storage = createEncryptedStore(
        cryptoAdapterNew,
        forageAdapterFactory(),
        {
          dbName: 'VSFPersistance'
        }
      )
      config.supportOffline = true
      break
    case 'default':
      config.storage = undefined
      config.supportOffline = true
      break
    case 'no-offline':
      config.storage = mockStorage
      config.supportOffline = false
      break
  }

  function clientWrapperFactory() {
    const clientReinitialize = () =>
      apolloClientFactory(
        awsconfig,
        config.storage,
        user,
        true,
        config.supportOffline,
        offlineManager,
        { fetchPolicy: 'network-only' }
      )

    let client = clientReinitialize()

    const result = new Proxy(
      {},
      {
        get(target, p, receiver) {
          if (p === '$reinitialize') {
            console.log('Reinitializing ApolloClient')
            client = clientReinitialize()
          } else {
            console.log('getting client property', p)
            return client[p]
          }
        },
        set(target, p, value, receiver) {
          client[p] = value
          console.log('setting client property', p)
          return true
        }
      }
    )

    return result
  }

  const client = clientWrapperFactory()
  const provider = new VueApollo({
    defaultClient: client
  })
  // something to do
  Vue.use(VueApollo)
  Vue.use(AsyncComputed)
  app.apolloProvider = provider
  app.apolloClient = client
  app.axiosGraphQL = axiosGraphQLFactory({
    url: awsconfig.AppSync.Default.ApiUrl,
    apiKey: awsconfig.AppSync.Default.ApiKey
  })
  Vue.prototype.$apolloClient = client
  Vue.prototype.$offlineManager = offlineManager
  app.storage = config.storage
  app.$offlineManager = offlineManager
}

src/lib/apolloClient.js

export default function apolloClientFactory(
  awsconfig,
  storage,
  user,
  connectToDevTools,
  supportOffline,
  offlineManager,
  opts = {}
) {
  const clientFn = require('../lib/apollo-libs/aws.js').default

  function getTokenFn() {
    return new Promise(async (resolve, reject) => {
      const userInstance = await user.getUser()
      resolve(userInstance.token)
    })
  }

  function getAlwaysTokenFn() {
    return new Promise(async (resolve, reject) => {
      resolve('always')
    })
  }

  const localOpts = {
    fetchPolicy: 'cache-and-network',
    ...opts
  }

  console.log({ offlineManager })

  return clientFn(
    awsconfig,
    storage,
    getTokenFn,
    connectToDevTools,
    supportOffline,
    offlineManager,
    localOpts
  )
}

src/lib/apollo-libs/aws.js

// import AWSAppSyncClient, { createAppSyncLink } from 'aws-appsync'
import AWSAppSyncClient, { createAppSyncLink } from 'src/vendor/aws-appsync/lib'
import { setContext } from 'apollo-link-context'

export default function clientFn(
  awsconfig,
  storage,
  getTokenFn,
  connectToDevTools,
  supportOffline,
  offlineManager,
  { fetchPolicy }
) {
  console.log('registering handler', offlineManager)
  const primaryOfflineConfig = {
    callback: offlineManager.$offlineHandler,
    fetchPolicy: 'cache-and-network'
  }
  const offlineConfig = storage
    ? { storage, ...primaryOfflineConfig }
    : primaryOfflineConfig

  const primaryConfig = supportOffline
    ? {
        offlineConfig,
        disableOffline: false
      }
    : {
        disableOffline: true
      }

  const customLink = createAppSyncLink({
    url: awsconfig.AppSync.Default.ApiUrl,
    region: awsconfig.AppSync.Default.Region,
    auth: {
      type: awsconfig.AppSync.Default.AuthMode,
      apiKey: awsconfig.AppSync.Default.ApiKey
    }
  })

  const contextSetterLink = setContext(async (operation, { headers }) => {
    const token = await getTokenFn()
    return {
      headers: {
        ...headers,
        'x-naep-token': token
      }
    }
  })

  const options = {
    defaultOptions: {
      watchQuery: {
        fetchPolicy
      },
      connectToDevTools, // Remove this for production use
      disableOffline: !supportOffline
    },
    link: contextSetterLink.concat(customLink)
  }

  const client = new AWSAppSyncClient(primaryConfig, options)
  return client
}