Skip to content

Instantly share code, notes, and snippets.

@goldoraf
Last active September 2, 2019 12:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save goldoraf/a4a9acde57002a8da8734810f9fb347d to your computer and use it in GitHub Desktop.
Save goldoraf/a4a9acde57002a8da8734810f9fb347d to your computer and use it in GitHub Desktop.

cozy-client : objet et roadmap

Ce document présente cozy-client à la fois dans son état actuel et dans sa vision, et propose une roadmap des évolutions souhaitables. L'objectif est double : informer et recueillir du feedback d'une part, mais aussi et surtout permettre à l'ensemble des devs front Cozy de contribuer à cette évolution.

Origine du projet

cozy-client (anciennement redux-cozy-client) est né de la volonté de limiter au maximum le boilerplate typique des actions asynchrones avec redux-thunk dans nos applications et de centraliser dans le store redux l'état des différents fetches exécutés via cozy-client-js et d'y stocker les documents fetchés de manière normalisée. Il va donc au delà de cozy-client-js en proposant un moyen de "brancher" des composants (p)React sur des données issues de la stack cozy et de disposer de toutes les infos nécessaires pour l'UI (la requête est-elle en cours / doit-on afficher un spinner ? Y a t-il d'autres documents à fetcher ? Combien y en t-il au total sur le serveur ? etc...).

Présentation

Architecture

archi

cozy-client est actuellement fait de 4 parties :

  • une couche basse, le client en lui-même, qui s'appuie sur 2 adapters pour récupérer les données depuis 2 sources différentes :   - CozyStackAdapter qui wrappe cozy-client-js pour récupérer des données depuis la stack,   - PouchDBAdapter pour récupérer des données depuis un pouch local synchronisé avec la stack.   Le client se base sur sa config (doctypes.offline) pour déterminer l'adapter à utiliser pour chaque appel.
import { CozyClient } from 'cozy-client'

const client = new CozyClient({...})
client.fetchDocument('io.cozy.todos', 'e8354db7abfef08b7a14e43b2b106a9d')
  .then(document => { /* do something with the document */ })
  • une couche haute qui expose des sélecteurs et créateurs d'actions redux. Afin de réduire le boilerplate, les actions crées sont d'un format spécifique à cozy-client et nécessite la mise en place d'un middleware spécifique pour être traitées. En effet, on distingue traditionnellement 2 types d'action creators redux :
  • les synchrones :
export const receiveData = (data) => ({
 type: RECEIVE_DATA,
 data
})

 - les asynchrones, qui nécessitent le middleware redux-thunk et que l'on utilise pour fetcher des données en dispatchant des action synchrones aux instants clés du cycle de vie du fetch :

export const fetchDocument = (doctype, id) => (dispatch, getState) => {
 dispatch({ type: FETCH_DOCUMENT, doctype, id })
 return client.fetchDocument(doctype, id)
  .then(resp => dispatch({ type: RECEIVE_DATA, data: resp.data }))
  .catch(err => dispatch({ type: RECEIVE_ERROR, error: err }))
}

Les actions asynchrones deviennent vite verbeuses, et pourtant elles ont un motif commun : on dispatche une action avant d'exécuter le fetch, on dispatche une action en cas de succès, et une autre en cas d'erreur. Afin d'alléger ces action creators, cozy-client expose des actions creators qui n'ont pas de propriété type mais types et une propriété promise contenant une fonction exécutable avec une instance du client passée en argument :

export const fetchDocument = (doctype, id) => ({
  types: [FETCH_DOCUMENT, RECEIVE_DATA, RECEIVE_ERROR],
  doctype,
  id,
  promise: client => client.fetchDocument(doctype, id)
})
  • le middleware redux de cozy-client, qui reçoit une instance du client lors de son instantiation, reconnait ce format d'action et exécute la promise en dispatchant les actions types :

     const { types, promise } = action
     const [REQUEST, SUCCESS, FAILURE] = types
     next({ ...rest, type: REQUEST })
    
     return promise(client, dispatch, getState))
       .then(
         response => {
           next({ ...rest, response, type: SUCCESS })
           return response
         },
         error => {
           console.log(error)
           next({ ...rest, error, type: FAILURE })
         }
       )
       .catch(error => {
         console.error('MIDDLEWARE ERROR:', error)
         next({ ...rest, error, type: FAILURE })
       })
    }
  • un HOC, cozyConnect, qui permet à un composant de décrire les données dont il a besoin (c'est la vision en tout cas). Concrètement, il permet de se passer du boilerplate typique de l'utilisation du connect de react-redux, à savoir dispatcher les actions de fetches injectées grâce au mapDispatchToProps dans le componentDidMount.

import React from 'react'
import { cozyConnect, fetchCollection } from 'cozy-client'

const TodoList = ({ todos }) => {
 const { data, fetchStatus } = todos
 if (fetchStatus !== 'loaded') {
   return <h1>Loading...</h1>
 }
 return (
   <ul>
     {data.map(todo => <li>{todo.label}</li>)}
   </ul>
 )
}

const mapDocumentsToProps = (ownProps) => ({
 todos: fetchCollection('todos', 'io.cozy.todos')
})

export default cozyConnect(mapDocumentsToProps)(TodoList)

When we use cozyConnect to wrap a component, three things happen:

  • The actions specified by the mapDocumentsToProps function (here the fetchCollection call) will be dispatched when the component mounts, resulting in the loading of data from the client-side store, or the server if the data is not in the store
  • Our component subscribes to the store, so that it is updated if the data changes
  • props are injected into the component: in our case, a todos prop. If we were to declare propTypes they would look like this:
TodoList.propTypes = {
  todos: PropTypes.shape({
    fetchStatus: PropTypes.string.isRequired,
    data: PropTypes.array
  })
}

Structure des props injectées par cozy-connect

As seen above, cozyConnect will pass the result of the collection fetch to the wrapped component in a prop whose name is specified in the mapDocumentsToProps function. For collections fetches, the shape of the injected prop is the following:

  • data: an array of documents
  • fetchStatus: the status of the fetch (pending, loading, loaded or error)
  • lastFetch: when the last fetch occured
  • hasMore: the fetches being paginated, this property indicates if there are more documents to load
  • fetchMore: a function you can call to trigger the fetching of the next page of data

Collections et structure du store

De manière générale, on a besoin pour un composant de fetcher un unique document (et ses relations, mais ceci est une autre histoire) ou une liste (filtrée et/ou triée ou non) de documents d'un même doctype, que l'on appellera collection. fetchCollection est l'action creator dont le dispatch permet de récupérer une liste de documents. Son premier argument est le nom de la collection (par exemple timeline pour la liste des photos récentes), le deuxième le doctype et le troisième les options de la requête :

export default cozyConnect(ownProps => ({ 
  photos: fetchCollection(
    'timeline',
    'io.cozy.files',
    {
      fields: ['dir_id', 'name', 'size', 'updated_at', 'metadata'],
      selector: {
        class: 'image',
        trashed: false
      },
      sort: {
        'metadata.datetime': 'desc'
      }
    }
  )
}))(Timeline)

Afin de persister le résultat de ces fetches dans le store de manière normalisée, on stocke d'un côté tous les documents regroupés par doctypes et de l'autre des listes d'IDs correspondant aux collections fetchées. On a donc un store ressemblant à :

{
  cozy: {
    collections: {
      timeline: {
        count: 214,
        fetchStatus: 'loaded',
        hasMore: true,
        ids: [
          ...
        ],
        lastFetch: ...,
        options: ...,
        type: 'io.cozy.files'
      }
    },
    documents: {
      'io.cozy.files': {
        0e80e10d37e791a94ccb4f7cff2a9cfe: {
          _id: 0e80e10d37e791a94ccb4f7cff2a9cfe,
          ...
        }
      }
    },
    sharings: {
      documents: [],
      permissions: {}
    },
    synchronization: {
      doctypes: {},
      initialStatus: 'pending',
      started: false
    }
  }
}

Cas particulier du partage

Comme vous pouvez le constater ci-dessus, en raison de certaines spécificités de l'API du partage, les sharings et permissions sont persistés à part. C'est loin d'être idéal, et suite aux derniers travaux du back sur cette API, on pourrait envisager de normaliser ça.

Evolution

L'architecture actuelle de cozy-client nous a déjà permis de bien réduire le boilerplate redux dans Photos, mais elle présente 2 problèmes :

  • une certaine rigidité (format des actions par ex.) qui pousse trop de responsabilités vers la couche basse, le client (choix de l'adaptateur et donc de la source de données par ex.). Conséquence : l'ajout de certaines fonctionnalités est plus compliqué ;
  • beaucoup de "pièces mobiles" (actions, selectors) sont exposées, ce qui complique les refactos de la lib, et son utilisation par les devs.

Vision

Une influence majeure dans le développement de cozy-client fût Apollo, un client GraphQL pour JS, et en particulier sa couche d'intégration avec React. Apollo propose en effet de nombreuses fonctionnalités très pratiques pour le développeur, comme les fetch policies ou l'optimistic UI, fonctionnalités qu'il serait très intéressant d'avoir dans cozy-client. Hors, Apollo n'expose pas (ou plus) du tout sa couche redux. C'est ce qui fait que son API reste simple à utiliser.

Ce que l'on vise donc avec cozy-client est une lib exposant seulement 2 pièces :

  • un client, proche de cozy-client-js mais avec une API repensée,
  • un HOC de "binding" de données cozy avec des composants React.

On notera également qu'il existe des similitudes entre la raison d'être de GraphQL, à savoir un langage de requête sur n'importe quelle API côté serveur, et nos besoins à nous, développant sur la stack cozy : en effet, pour afficher une page, nous avons en général besoin de requêter différents types de données depuis la stack. Par exemple, la liste des albums de Photos nécessite de fetcher :

  • les albums
  • les albums partagés
  • les fichiers référencés par les albums

Comme nous venons de le voir, actuellement nous passons à cozyConnect un objet dont les propriétés sont des actions dont le dispatch va entrainer le fetch des données correspondantes :

const mapDocumentsToProps = ownProps => ({
  albums: fetchAlbums(),
  sharings: fetchSharedAlbums()
})

export default cozyConnect(mapDocumentsToProps)(AlbumsView)

Mais ces actions de fetch forment un tout indissociable : ce sont des fragments d'une seule et même requête (pour reprendre des termes GraphQL). Donc au final, cet objet passé à cozyConnect est une forme de description de requête, une QueryDefinition ;) Que l'on pourrait exprimer de façon plus élégante, par exemple :

const mapDocumentsToProps = ownProps => ({
  albums: all('io.cozy.albums').include([ 'files', 'shared'])
})

export default cozyConnect(mapDocumentsToProps)(AlbumsView)

Ce que l'on souhaite obtenir au final (et qui est esquissé ci-dessus), c'est un DSL générant des définitions de "requêtes cozy", et que ces QueryDefinition ne soient plus des actions redux (car manipuler des actions et action creators redux comme on le fait pour l'instant, ça sent un peu la leaky abstraction), mais des objets décrivant à cozy-client les données qu'il doit fetcher pour répondre à la requête. On doit donc introduire un nouvel élément dont le rôle sera :

  • d'interpréter ces définitions de requêtes,
  • d'appeller des méthodes du/des clients pour fetcher les données (plutôt que de les inclure dans les actions, ce qui impose d'avoir un middleware et met trop de responsabilités sur le client, en particulier de devoir gérer 2 sources de données distincts),
  • de dispatcher des actions pour mettre à jour le store et notamment l'état des différentes requêtes (rôle du middleware actuellement),
  • de s'assurer qu'on n'exécute pas pour rien plusieurs fois la même requête, et ce, sans forcer l'utilisateur à préciser un nom pour la requête comme c'est le cas actuellement pour les collections. C'est possible par une simple comparaison profonde des QueryDefinition, objets qui ne seront jamais très gros de toute façon.

Cet élément central pourrait s'appeller QueryManager (comme dans Apollo ;), à moins que quelqu'un ait une meilleure idée.

Premiers pas

Se débarrasser du middleware spécifique

Comme expliqué plus haut, cozy-client nécessite actuellement un middleware redux spécifique : ce middleware régle de façon plutôt élégante le problème du boilerplate redux typique des actions asynchrones car il sait gérer les actions types: [<TRUC>_REQUEST, <TRUC>_SUCCESS, <TRUC_ERROR] qu'émet cozy-client. Mais au final, cela apporte plus de contraintes qu'autre chose, notamment parce que cela impose un boilerplate de config redux particulier, mais aussi parce que cela force un cadre très rigide pour nos actions redux.

S'en débarasser serait l'occasion d'introduire ce QueryManager, qui serait instancié et fourni en contexte par le CozyProvider :

import { Component } from 'react'
import PropTypes from 'prop-types'

import QueryManager from './QueryManager'

export default class CozyProvider extends Component {
  static propTypes = {
    store: PropTypes.shape({
      subscribe: PropTypes.func.isRequired,
      dispatch: PropTypes.func.isRequired,
      getState: PropTypes.func.isRequired
    }),
    client: PropTypes.object.isRequired,
    children: PropTypes.element.isRequired
  }

  static childContextTypes = {
    store: PropTypes.object,
    client: PropTypes.object.isRequired
  }

  static contextTypes = {
    store: PropTypes.object
  }

  constructor(props) {
    super(props)
    this.queryManager = new QueryManager(props.client, props.store)
  }

  getChildContext() {
    return {
      store: this.props.store || this.context.store,
      client: this.props.client,
      queryManager: this.queryManager
    }
  }

  render() {
    return (this.props.children && this.props.children[0]) || null
  }
}

Dans un premier temps, le QueryManager se contente donc de remplir le rôle du middleware, et devra être appellé par cozyConnect pour dispatcher sous forme thunk les actions de fetch de cozy-client.

Evolution de cozyConnect et des props injectées [BREAKING CHANGE!]

Aujourd'hui, lorsqu'on utilise connect ainsi :

export default cozyConnect(ownProps => ({
  albums: fetchAlbums(),
  sharings: fetchSharedAlbums()
}))(AlbumsView)

On récupère 2 props dans AlbumsView, albums et sharings, avec chacun un fetchStatus, qui de surcroit change d'état à des moments différents, d'où des rerenders et flashes d'UI. La raison en est que connect se contente de dispatcher les actions de fetch retournées par fetchAlbums et fetchSharedAlbums.

Il nous faut désormais considérer l'objet retourné en argument de connect comme un objet représentant une seule et même query, certes composée de plusieurs fetches car c'est le lot de toute API REST.

L'évolution souhaitée ici est donc de modifier le QueryManager afin qu'il gère l'exécution des différents fetches dans le contexte d'une requête dont il persiste le statut dans le store via un nouveau reducer (ou pas : dans un premier temps on pourrait se contenter de déterminer le status de la requête à partir des fetchStatus des différents fetches). connect devra être modifié afin qu'une unique prop data soit injectée, de la forme :

data: {
  albums: [...],
  sharings: [...],
  loading: false,
  error: null,
  refetch() { ... },
  fetchMore() { ... }
}

Empêcher une même requête d'être exécutée plusieurs fois en parallèle

Dans Drive ou Photos, il peut arriver (par exemple lorsqu'on accède au contenu d'un album via une URL) que les mêmes fetches soient dispatchées plusieurs fois. L'idée ici est donc de faire en sorte que le QueryManager génère une ID pour chaque requête et stocke dans le store l'ID ainsi que la définition de la requête, à savoir l'objet pour l'instant composé de plusieurs actions de fetches. Lorsqu'on exécute une requête, on fait une comparaison profonde de sa définition avec celles présentes dans le store, et si la requête est déjà présente, on en utilise l'ID et on se "branche" sur le résultat de cette requête.

Injecter plus d'actions de mise à jour via cozyConnect

Pour l'instant, les actions de "C(R)UD" dont le composant a besoin doivent être passées en 2ème argument de cozyConnect, via un mapDispatchToProps typique de redux :

const mapDocumentsToProps = ownProps => ({
  album: fetchAlbum(ownProps.router.params.albumId)
})

export const mapDispatchToProps = (dispatch, ownProps) => ({
  updateAlbum: album => dispatch(updateAlbum(album))
})

export default cozyConnect(mapDocumentsToProps, mapDispatchToProps)(AlbumPhotos)

Là encore, l'abstraction fuit, et on pourrait s'en passer étant donné qu'on peut facilement déterminer à partir de la requête les actions possibles sur les résultats de celle-ci.

L'idée est donc de modifier cozyConnect de telle façon qu'il injecte plus d'actions à l'image du fetchMore :

data: {
  albums: [...],
  sharings: [...],
  loading: false,
  error: null,
  refetch(),
  fetchMore(),
  create(<properties>),
  update(<id>, <properties>),
  destroy(<id>),
  shareWith(<document>, <recipients>),
  shareByLink(<document>),
  revokeFor(<document>, <recipients>),
  revokeLink(<document>),
  ...
}

Notez bien que les actions à injecter diffèrent en fonction de la requête (et de ses fragments) : ci-dessus on injecte shareWith, shareByLink, etc... car le fetch des albums partagés fait partie de la requête.

Si l'on fetche un simple album et ses fichiers référencés, les actions injectées sont différentes (et plus adaptées) :

data: {
  album: <document>,
  photos: [...] // les fichiers référencés,
  loading: false,
  error: null,
  refetch(),
  fetchMore(),
  update(<properties>), // pas besoin d'ID, cette action est pré-bindée sur l'album fetché
  destroy(),
  addFile(<file>), // actions sur les fichiers référencés
  removeFile(<file>),
  ...
}

Simplification/généralisation des reducers et actions [ASSEZ GROS MORCEAU]

Actuellement, nous avons des reducers spécifiques pour les différents types de fetches : documents, sharings, fichiers référencés... Nous avons également un grand nombre d'actions différentes, toutes du type types: [<TRUC>_REQUEST, <TRUC>_SUCCESS, <TRUC>_ERROR]. Il y a clairement un motif autour des requêtes et des mises à jour asynchrones, à savoir que l'on émet avant une action pour "flagger" le fetch à venir dans le store, qu'en cas de succès, on émet une action dont la charge utile permet de mettre à jour le store, et qu'en cas d'échec on émet une action pour "flagger" le fetch comme étant en erreur. Il conviendrait donc de factoriser quelque part ce motif plutôt que d'avoir des actions aussi verbeuses.

D'autre part, et comme nous l'avons vu plus haut, l'objectif "ultime" est de ne plus exposer de créateurs d'actions redux, mais un DSL de création de définitions de requêtes, définitions qui seront passées en argument de cozyConnect. Ben c'est le moment ! ;)

L'idée est d'employer une abstraction inspirée de GraphQL ; en GraphQL, il n'y a en effet que 2 types d'opérations : les Query pour récupérer des données et les Mutation pour les mettre à jour. Nous allons donc remplacer nos action creators par des QueryDefinition creators et des MutationDefinition creators : on considérera désormais qu'une définition de requête passée en argument de cozyConnect est composée de N fragments correspondants aux différents fetches nécessaires pour répondre à la requête (ex. pour un album : fetch du document album, fetch des albums partagés, fetch des fichiers références par l'album). Nos action creators deviendraient ainsi quelque chose dans ce genre :

export const FragmentTypes = {
  DOCUMENT: 'DOCUMENT',
  COLLECTION: 'COLLECTION',
  REFERENCED_FILES: 'REFERENCED_FILES'
}

export const fetchCollection = (doctype, options = {}, skip = 0) => ({
  fragmentType: FragmentTypes.COLLECTION,
  doctype,
  ...options,
  skip,
  promise: client => client.getCollection(doctype).find(options, skip)
})

export const fetchReferencedFiles = (doc, skip = 0) => ({
  fragmentType: FragmentTypes.REFERENCED_FILES,
  referencedBy: doc,
  skip,
  promise: client =>
    client.getCollection('io.cozy.files').findReferencedBy(doc, skip)
})

export const fetchDocument = (doctype, id) => ({
  fragmentType: FragmentTypes.DOCUMENT,
  doctype,
  id,
  promise: client => client.getCollection(doctype).get(id)
})

// etc...

export const MutationTypes = {
  CREATE: 'CREATE',
  UPDATE: 'UPDATE',
  DESTROY: 'DESTROY',
  SHARE: 'SHARE'
  // etc...
}

export const createDocument = (doctype, properties) => ({
  mutationType: MutationTypes.CREATE,
  doctype,
  properties,
  promise: client => client.getCollection(doctype).create(properties)
})

// etc...

Et c'est donc le QueryManager qui va désormais dispatcher des actions génériques pour mettre à jour le store quand il exécute une query ou une mutation. En pseudocode, cela donnerait :

class QueryManager {
  // ...
  query(queryId, queryDefinition) {
    // note: le queryId est généré ailleurs
    this.store.dispatch({ type: INIT_QUERY, queryId })
    return Promise.all(
      Object.keys(queryDefinition).map(fragmentName => {
        const fragment = queryDefinition[fragmentName]
        const fragmentId = `${queryId}#${fragmentName}`
        this.store.dispatch({
          type: FETCH_QUERY_FRAGMENT,
          fragmentId,
          ...fragment
        })
        return = fragment
          .promise(this.client)
          .then(response => {
            this.store.dispatch({
              type: RECEIVE_QUERY_FRAGMENT,
              fragmentId,
              response
            })
          })
          .catch(error => {
            this.store.dispatch({
              type: RECEIVE_QUERY_ERROR,
              fragmentId,
              error
            })
          })
      })
    ).then(() => this.store.dispatch({ type: RECEIVE_QUERY_RESULT, queryId }))
    .catch(() => this.store.dispatch({ type: RECEIVE_QUERY_ERROR, queryId }))
  }
  // ...
}

Par la suite

Modèle de souscription

TBD

Optimistic UI

TBD

Refacto de la partie cliente "pure"

Par partie cliente "pure", j'entends le code qui parle à la stack, donc la partie non-redux. Aujourd'hui, pour fetcher des documents avec ce client, on fait :

const resp = client.fetchDocuments('io.cozy.albums')

C'est donc le client qui détermine un adapter (stack ou pouch) à utiliser pour récupérer les données, en fonction de la config du client et en particulier des éventuels doctypes définis comme étant offline. Remarquez aussi que l'API du client est vaste et un peu fourre-tout avec les doctypes particuliers comme les fichiers ou les partages (client.fetchDocuments(), client.fetchFiles(), client.fetchReferencedFiles(), client.fetchSharings()

L'idée est donc d'avoir simplement 2 clients distincts : un pour la stack et un pour PouchDB, avec la même API simple :

const stackClient = new CozyStackClient(...)
const resp = stackClient.getCollection('io.cozy.albums').all()

Le client possède une méthode getCollection qui retourne pour un doctype donné un objet avec une API générique :

  • all()
  • find(...)
  • create(...)
  • update(...)
  • destroy(...)

Pour la plupart des doctypes, on utilisera donc une classe Collection générique. Pour les cas particuliers comme les fichiers ou les partages, on implémentera des classes spécifiques qui devront être register par le client et qui possèderont la même API de base, mais aussi quelques méthodes supplémentaires spécifiques (ex: findReferenced pour les fichiers ?)

Enfin, ce sera donc au QueryManager désormais de savoir quel client (stack ou pouch) utiliser pour une requête en fonction de la fetchPolicy choisie (cf ci-dessous).

[IMPORTANT !] Fetch policies

Afin de gagner en efficacité, il conviendrait de pouvoir récupérer le résultat d'une requête dans le store redux si celle-ci a déjà été exécutée récemment. On pourrait aussi vouloir requêter le Pouch et si pas de résultats parce que la synchro est encore en cours, faire un fallback sur la stack. C'est l'objectif de cette option fetchPolicy :

export default cozyConnect(ownProps => ({
  operations: all('io.cozy.bank.operations')
}), {
  fetchPolicy: 'cache-and-network'
})(OperationList)

Dans l'exemple ci-dessus, le QueryManager vérifierait en premier si tous les fragments nécessaires pour répondre à la requête sont déjà dans le store, et si oui, retournerait directement le résultat de la requête, avant d'exécuter la requête via la stack et de rafraichir ensuite le résultat.

Cf https://www.apollographql.com/docs/react/basics/queries.html#graphql-config-options-fetchPolicy

DSL de requêtage

Remplacer les action creators actuels (fetchCollection(), ...) ou les queryDefinitions creators qui les auront remplacés par un DSL fonctionnel permettant de façonner les définitions de requêtes :

const query = find('io.cozy.albums').where({ name: 'foo' })

Rapatriement du code de cozy-client-js dans cozy-client

Il n'est pas idéal de dépendre encore de cozy-client-js. Il faudrait donc rapatrier tout le code nécessaire pour les fetches dans cozy-client, ce qui inclut le code d'authent.

Extensibilité

TBD

Gestion des erreurs

Besoin d'un EXPLAIN pour aider l'user à comprendre pourquoi il n'a pas les données qu'il veut

Fonctionnalités "ORM-like"

Associations

TBD

counterCache

TBD

Migrations

TBD

@aenario
Copy link

aenario commented Dec 18, 2017

Packaging

D'expérience, gérer plusieurs package c'est galère (la joie de faire 3 npm publish pour corriger un bug)
Si on duplique le code entre cozy-client-js & cozy-client, cozy-client-js va diverger et pourrir, mais c'est dommage de se lier à 100% avec Redux+React (qui introduit des concepts pas évidents pour les habitués d'autres FrameWorks)
--> monorepo avec cozy-client-js & cozy-client ?

<3 Apollo & GraphQL

  • Si c'est vraiment la solution, on peut aussi évaluer l'ajout d'un endpoint graphql à la stack plutot que de re-engineer 3 niveau d'abstraction.

JSONAPI

Si on reste sur la JSONAPI, à la base on l'a quand même choisi pour le coté standard et la gestion des relations. Peut-être manque-t-il certains support d' ?include coté stack pour ne pas avoir à rajouter de la complexité coté client sur les references / partage / ect.

Gestion des doctypes

Rejoins un peu les Shemas Graphql

  • Plutot coté Stack AMHA

Intégrations Redux

Je suis heureux de voir qu'on a toujours les même galère depuis l'époque d'email & la V2, j'ai moins l'impression de m'être chier à l'époque.

Pour en revenir au besoins de bases :

  • Besoin d'unifier les données entre pouch / stack (+ realtime)
  • Besoin de stocker les infos en RAM dans un store normalisé
  • Besoin d'un équivalent mapStateToProps pour passer au composant les valeurs dénormalisés dont il a besoin pour se render (voir reselect)
  • Besoin de gérer le fetch de certaines data si on les a pas, quand on affiche un composant qui en a besoin, avec l'éternel problème de ne pas dispatch pendant le render.
  • Besoin de faire des actions.
  • Si realtime, besoin de gérer des requêtes en vols. Ie appliquer les actions coté local également en attendant qu'elle soit active coté serveur.

Analyse :

Je trouve que le mélange du mapStateToProps et des fetch Action rend la chose pas clair et je suis pas fan des actions injectés automagiquement en fonction de la collection.

Proposition :

  • Soit on admet que la programation fonctionnel c'est pas naturel et on part sur de l'OOP avec un objet collection (~ Promise<Immutable.List<Immutable.Record>> ?) qui contient du coup l'information de fetchStatus et les actions.

  • Soit on sépare le mapStateToProps des fetchActions, par exemple en pseudo code explicité :

AlbumPhoto = ({album, fetchStatus}) => {

}

AlbumPhoto.dataNeed = (props) => {
  NAME: 'fetching-album-' + props.id
  album: cozy.fetch('album', {id: props.id})
  sharings: cozy.fetch('sharings', {shared_id: props.id})
}

cozyConnect(
  mapStateToProp: (state, props) -> 
    album: cozyselect(state => state.albums[props.id])
    fetchStatus: cozyselect(state => !state.request['fetching-album-' + props.id].done)
  },
)(AlbumPhotos)

DSL

DSL de requêtage : si on trouve mango trop compliqué, peut-être mieux de l'abstraire au niveau de la stack (et gérer certains trucs qu'on doit faire en mapreduce) (wink GraphQL) plutot que de rajouter

@goldoraf
Copy link
Author

@aenario

Si on duplique le code entre cozy-client-js & cozy-client, cozy-client-js va diverger et pourrir, mais c'est dommage de se lier à 100% avec Redux+React

C'est pour cela qu'il y a plusieurs couches dans cozy-client : on peut juste utiliser le client comme on utilise cozy-client-js, sans toute la couche redux. Là où je te rejoins, c'est qu'on ne doit pas avoir cozy-client-js ET le client de cozy-client. L'avantage de réécrire le client dans cozy-client est qu'on repart d'une feuille vierge et on peut donc travailler sur une meilleure API. Mais il faudrait que tous les projets, en particulier le client desktop, migrent vers cozy-client à terme.

Si c'est vraiment la solution, on peut aussi évaluer l'ajout d'un endpoint graphql à la stack

Outre le fait que cela risque de prendre du temps et que vous avez sans doute d'autres priorités, Apollo ne règle pas tous les pbs : pas de synchro avec Pouch par exemple, et donc pas vraiment de offline (juste du cache). D'autre part, ce n'est pas spécialement GraphQL qui me plait, mais plus l'architecture d'Apollo et le fait que cette lib résoud plein de pbs typiques du dev front de façon très élégante.

Si on reste sur la JSONAPI, à la base on l'a quand même choisi pour le coté standard et la gestion des relations. Peut-être manque-t-il certains support d'include coté stack pour ne pas avoir à rajouter de la complexité coté client sur les references / partage / etc.

Oui, clairement, on pourrait faire certaines choses côté stack qui nous faciliteraient la vie. Cela nous permettra de réduire le nombre de requêtes nécessaires, mais ne résoudra pas tous nos pbs non plus. Encore une fois, ce sont des features type fetch policies qui m'intéressent dans Apollo, et dont je souhaiterais disposer dans cozy-client.

Je trouve que le mélange du mapStateToProps et des fetch Action rend la chose pas clair

C'est pour cette raison que je souhaiterais ne plus exposer la couche redux mais seulement le HOC. Les action et sélecteurs sont nécessaires à la lib, mais on ne devrait pas les faire manipuler par l'utilisateur.

et je suis pas fan des actions injectés automagiquement en fonction de la collection.

OK, pourquoi ? Principe de moindre surprise ? Parce que non seulement cela réduirait le boilerplate nécessaire à l'utilisation de la lib, mais surtout résoudrait le pb mentionné plus haut, à savoir que l'user ne devrait pas avoir à manipuler directement des actions redux. Pour la même raison, ta proposition en pseudocode ne m'emballe pas car cela implique pour l'user de connaitre les entrailles de cozy-client pour pouvoir l'utiliser.

si on trouve mango trop compliqué, peut-être mieux de l'abstraire au niveau de la stack

Je ne vois pas trop le rapport avec Mango, pour moi l'objectif de ce DSL est plus d'exprimer des requêtes sur des documents avec des associations, du include en somme.

@enguerran
Copy link

enguerran commented Dec 19, 2017

[posté à https://gist.github.com/enguerran/e4919167898e3aa611cc63d220bb7701]

Relecture de la documentation cozy-client

Avis personnel sur la bibliothèque

Il ne faut pas oublier que cette bibliothèque serait utiliser par des développeurs tiers voulant contribuer à la plateforme : permettre la DX la plus claire, pratique et utile possible.

Ce qui sous-tend qu'on part de l'API pour construire la bibliothèque :

  • couche basse : API « à la » fetch
  • couche haute + middleware + HOC : un composant est amélioré avec un HOC qui le connecte aux données du serveur.
    • fetch : le composant récupère des données du serveur et/ou de la duplication locale
    • post : le composant peut envoyer des données au serveur

Voir ci-dessous pour les détails et propositions, si l'analyse est correcte, on doit pouvoir pousser plus loin pour ameliorer le design.

Finalement je vois 3 couches :

  1. client REST
  2. redux (des actions creators tout prêt)
  3. HOC et middleware

Une dernière chose : explicit is better than implicit. Même dans le cadre d'une abstraction.

Les couches

couche basse

  • lister les fonctions de l'API
  • décrire les fonctions de l'API
  • présenter un getting started

couche haute

  • comparer les cozy action creator et les action creator redux/redux-thunk
  • expliquer à quoi servent les types
  • décrire les types attendu

middleware cozy et HOC

  • donner le détail en pseudo-code du workflow de dispatch

Est-ce qu'il ne faudrait pas un store exprès plutôt qu'un middleware ? Puisqu'on fournit un connecteur, on pourrait très bien permettre aux développeurs de brancher leurs composants 2 fois : à leur propre store + à notre store.

const ViewCounter = ({document, counter}) => (
  <p>Le document {document.name} a été vu {counter} fois</p>
)

const mapDocumentsToProps = ownProps => ({
  document: fetchDocument(ownProps.doctype, ownProps.id)
})

cozyConnect(mapDocumentsToProps)(
  connect(state => ({ counter: getCounter(document.id) })(
    ViewCounter
  )
)
  • ajouter les PropTypes à tous les composants cozyConnectés
  • indiquer si lastFetch est un événement, une fonction ou un booléen

Une autre proposition d'API pour le HOC :

const DocList = ({documents, addDocument}) => documents.fetchStatus !== 'loaded'
  ? (
  <p>Loading...</p>
  )
  : (
    <ul>
      {documents.map(document => <li>{document.name} - {document.created_date})}
    </ul>
    <input type="file" onChange={addDocument} />
  )

const connectedDocList = cozyConnect({
  documents: fetchCollection('io.cozy.file'),
  addDocument: createDocument('io.cozy.file')
})(DocList)

API

Kinto organise les données dans 3 niveaux :

  1. bucket
  2. collection
  3. record

Cozy-stack les organisent principalement autour de 2 niveaux :

  1. doctype
  2. document

Mais ajoute tout un tas d'information (https://cozy.github.io/cozy-stack/#services) :

  • intents
  • jobs
  • permissions
  • notifications
  • jobs
  • realtime
  • remote/proxy
  • settings
  • sharings

Pour ces cas particuliers qui ne sont pas des collections ou des documents (et qui ne sont pas forcément liés, ie Meta-Data, à des collections ou des documents), que faire pour l'API ?

Apollo

Quelles sont les différences entre apollo et relay ?

Apollo propose en effet de nombreuses fonctionnalités très pratiques pour le développeur, comme les fetch policies ou l'optimistic UI, fonctionnalités qu'il serait très intéressant d'avoir dans cozy-client, mais qui nécessitent d'en repenser l'architecture.

Quelle serait l'architecture qui permettrait d'avoir ces fonctionnalités intéressantes ?

Par exemple, la liste des albums de Photos nécessite de fetcher :

  • les albums
  • les albums partagés
  • les fichiers référencés par les albums

Pour moi, il faut bien séparer les données et les fonctionnalités. Pour "la liste des albums", il faut fetcher les albums. Auxquels on peut ajouter les albums partagés.

Je ne vois pas d'incompatibilité entre un serveur REST et des composants React. Bien que je comprenne l'intérêt d'un serveur graphQL pour permettre à différentes applications de consommer les données comme bon leur semble.

all('io.cozy.albums').include([ 'files', 'shared'])

Cette API est pas mal, couvre-t-elle tous les besoins que pourraient avoir des applications tierces ? Je ne sais pas encore répondre.

Si c'est le cas, alors l'abstraction sous forme de QueryDefinition est pertinente.

Si ce n'est pas le cas, alors on est bien encore dans une leaky abstraction.

Quoiqu'il en soit, cette abstraction sous forme de QueryDefinition doit se faire dans une des couches de la lib. Pas dans la couche basse car ça n'a rien à voir avec l'API REST du serveur, pas dans la couche react-redux car quelqu'un peut ne pas vouloir utiliser cette abstraction. Donc dans une 5e couche ? Dans le HOC CozyConnect ?

Pourquoi ne pas utiliser Apollo directement plutôt que de tout redévelopper ?

Est-ce qu'on ne peut pas développer une lib Apollo-like pour API REST qui serait utilisée par cozy-client pour consommer cozy-stack ?

@enguerran
Copy link

_[posté à https://gist.github.com/enguerran/e4919167898e3aa611cc63d220bb7701#file-questions-suite-a-reunion-md]

mon avis sur le state interne de cozy-client

Le state redux que se gère cozy-client doit être interne :

  • il est mis à jour uniquement par les mécaniques internes de cozy-client ;
  • il peut-être lu par tout un chacun via des sélecteurs ou directement avec le state ;
  • il permet de gérer la persistence ou le caching des données.

questions

apollo

question

Apollo ? Popularité ? Apollo pour serveur REST plutôt que graphQL ?

réponse

  • la popularité d'apollo n'est pas un bon marqueur de qualité
  • il n'existe pas à notre connaissance d'équivalent apollo pour du serveur REST
  • apollo n'est pas une leak abstraction de fetch

packaging

question

Packaging ? plusieurs libs ? Quid des dev angular/backbone/vue ?

réponse

  • possibilités de publier plusieurs packages (yarn workspace ou lerna)
  • pas d'API spécifique pour les développeurs non react
  • 2 couches : l'api client pure (équivalent de cozy-client-js) + cozyconnect

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment