ahdinosaur / inux

an experiment in opinionated helpers for `inu`
18 stars 4 forks source link

v3 #8

Open ahdinosaur opened 8 years ago

ahdinosaur commented 8 years ago

here's an issue to pitch breaking changes for inux:

ahdinosaur commented 8 years ago

remove do function

i'm starting to understand why it makes sense that you can only dispatch actions. by allowing do you are able to dispatch effects as well, which is nice sugar, but feels a bit inconsistent.

ahdinosaur commented 7 years ago

so @pietgeursen have been discussing the next version of inux. if this works well, the plan is to rename the current inu to inu-engine, extract all the helpers defined here to separate modules, and promote inux to replace inu using this more opinionated approach.

feedback welcome! :heart:

/cc @pietgeursen @iainkirkpatrick @queckezz @mixmix

inux@3 proposal:

api

actionCreator = inux.Action({ name, update[, scope] })

an "action creator" is a function of shape (payload) => ({ type, payload }).

attached to this function are properties: id, name, update, and scope.

id is a unique identifier (such as a Symbol, maybe a unique string). the type property in a created action object is equal to the action type's id.

update is a function (model, payload) => { model, effect }.

scope is an optional Array path to scope the update function to.

inux will provide a helper inux.Action which given { name, update } can automatically generate an action creator.

for example:

// cats/actions/create.js
const { Action } = require('inux')
const Id = require('uuid')

module.exports = Action({
  name: 'create',
  update: (model, newCat) => {
    const id = Id()
    return {
      [id]: { id, ...newCat },
      ...model
    }
  }
})

effectCreator = inux.Effect({ name, run })

an "effect creator" is a function of shape (payload) => ({ type, payload }).

attached to this function are properties: id, name, and run.

id is a unique identifier (such as a Symbol, maybe a unique string). the type property in a created effect object is equal to the effect type's id.

run is a function (payload) => pull.values(actions).

inux will provide a helper inux.Effect which given { name, run } can automatically generate an effect creator.

for example:

// cats/effects/load.js
const { Effect } = require('inux')
const Id = require('uuid')
const pullContinuable = require('pull-cont')

const setCat = require('../actions/set')

module.exports = (config, api) => Effect({
  name: 'load',
  run: (cat) => {
    const path = config.catsPath
    return pullContinuable(cb => {
      fs.readFile(path, 'utf8', (err, catString) => {
        if (err) return cb(err)
        try {
          var cat = JSON.parse(catString)
        } catch (err) {
          return cb(err)
        }
        cb(null, setCat(cat))
      })
    })
})

store = inux.Store({ name, state, actions, effects, routes })

a "store" is a state container that begins with an initial state, updates state with new actions, runs any effects, and navigates to any routes.

inux will provide a helper inux.Store which given { name, state, actions, effects } can automatically generate an inu compatible app (with routes attached).

// cats/store.js
module.exports = (config, api) => Store({
  name: 'cats',
  state: {},
  actions: [
    require('./actions/create'),
    require('./actions/set')
  ],
  effects: [
    require('./effects/load')(config, api)
  ],
  routes: [
    ['/', require('./pages/index')]
  ]
})

you can also pass in an Array of such objects (or inu compatible apps), which will be combined together.

sources = inux.start({ init, update, run, routes })

inux.start is a function that receives the app, connects the streams pipelines, and returns read-only streams of the pipelines.

it adds to inu.start by adding a store to track href changes, sets app.view using route(routes, app), and enhances resulting app with inu-multi.

open questions

how to inject config or api

it's common for effect handlers to need access to the config or client-side api in order to communicate with the server, etc. it'd be great to have a consistent way to inject these properties, similar to how it's done in vas.

unhandled errors shouldn't break the pipeline, right?

at the moment, an unhandled error returned from an effect handler stream will break the pipeline. i think this shouldn't happen, so making a note here.

should we add a way to define action types, creators, and handlers inline with the store definition?

what if we kept something like the existing api as well:

// cats/store.js
const cats = Store({
  name: 'cats',
  state: {},
  actions: {
    set: (model, cat) => ({
      model: {
        [cat.id]: cat,
        ...model
      }
    })
  }
  effects: {
    load: (config, api) => (payload, sources) => {
      // ...
    }
  }
  routes: [
    ['/', require('./pages/index')]
  ]
})

and attached the action creators to the returned store object.

cats.create({})

notice that this would require a solution to the (config, api) injection problem, since we need the store to be accessible at the top-level to be synchronously required elsewhere.

ahdinosaur commented 7 years ago

question:

let's say we create an "action" like so:

const set = Action({
  name: 'set',
  update: (model, cat) => {
    return {
      [cat.id]: cat,
      ...model
    }
  }
})

const store = {
  name: 'cats',
  state: { model: {} },
  actions: [
    set
  }
}

does it make sense if the test is like the following:

test('cat action creator', t => {
  const cat = { id:0, name: 'fluffy' }
  const actualAction = create(cat)
  const expectedAction = {
    type: create.type,
    payload: cat
  }
  t.deepEqual(actualAction, expectedAction)
  const model = {}
  const expectedState = { model: { [cat.id]: cat } }
  const actualState = create.update(model, actualAction)
  t.deepEqual(actualState, expectedState)
})

test('cat store', t => {
  const cat = { id:0, name: 'fluffy' }
  const action = create(cat)
  const model = store.state.model
  const expectedState = { model: { cats: { [cat.id]: cat } } }
  const actualState = store.update(model, actualAction)
  t.deepEqual(actualState, expectedState)
})

basically, i want each action creator to be given the opportunity to re-scope itself, as in i quite often wish one update function could be given the entire state (maybe it needs to access objects from another store), even if all the other update functions in that store should be scoped normally. so, i'm thinking that when action creators are passed into the store, they should be un-scoped and then the store scopes them, either with the default scope (the store's path) or the custom scope. this means that the returned .update function on a store is scoped, whereas before this scoping was handled by the combine method (i'm not sure combine should do this anymore if an action's update needs to scope itself).

current plan is to split inux into inu-store (Action, Effect, Store) and inu-combine (combine many pre-scoped stores into one). but still thinking about this.

ahdinosaur commented 7 years ago

okay, going to throw in a radical catstack api possible with depject:


// catstack service
const service = {
  needs: {
    agents: {
      get: 'first'
    }
  },
  gives: {
    profiles: {
      get: true,
      find: true,
      create: true,
      update: true,
      remove: true
    }
  },
  name: 'profiles',
  create: (config, api) => ({
    methods: {
      get,
      find,
      create,
      update,
      remove
    },
    hooks: {
      create: [auth],
      update: [auth],
      remove: [auth]
    }
  })
}

// catstack store
const pull = require('pull-stream')
const pullCont = require('pull-cont')

const store = {
  needs: {
    profiles: {
      get: 'first'
    }
  },
  gives: {
    store: true,
    routes: true
  },
  create: (config, api) => ({
    store: {
      name: 'profiles',
      state: {},
      actions: [{
        name: 'set',
        update: (model, profile) => ({
          model: { ...model, [profile.id]: profile }
        })
      }],
      effects: [{
        name: 'fetch',
        run: (id) => {
          return pullCont(cb => {
            api.profiles.get(id, (err, profile) => {
              if (err) cb(err)
              else cb(null, pull.values([profile]))
            })
          })
        }
      }]
    },
    routes: [
      ['/', MainPage]
    ]
  })
}

const render = {
  needs: {
    store: 'map',
    routes: 'map'
  },
  gives: 'render',
  create: (api) => {
    const store = combineStores(api.store)
    const routes = combineRoutes(api.routes)
    // yada yada
    return app
  }
}

which could be abstracted away so the user only has to say whether it is a service module or a store module, then combine the modules and start your desired entry point.

mixmix commented 7 years ago

Ooo. Seems to add a really nice semantic structure. Not sure how pseudocode this is. Bits that i was surprised by :

On Mon, 19 Dec 2016 13:33 Mikey notifications@github.com wrote:

okay, going to throw in a radical catstack api possible with depject https://github.com/dominictarr/depject:

// catstack serviceconst service = { needs: { agents: { get: 'first' } }, gives: { profiles: { get: true, find: true, create: true, update: true, remove: true } }, name: 'profiles', create: (config, api) => ({ methods: { get, find, create, update, remove }, hooks: { create: [auth], update: [auth], remove: [auth] } }) } // catstack storeconst pull = require('pull-stream')const pullCont = require('pull-cont') const store = { needs: { profiles: { get: 'first' } }, gives: { store: true, routes: true }, create: (config, api) => ({ store: { name: 'profiles', state: {}, actions: [{ name: 'set', update: (model, profile) => ({ model: { ...model, [profile.id]: profile } }) }], effects: [{ name: 'fetch', run: (id) => { return pullCont(cb => { api.profiles.get(id, (err, profile) => { if (err) cb(err) else cb(null, pull.values([profile])) }) }) } }] }, routes: [ ['/', MainPage] ] }) } const render = { needs: { store: 'map', routes: 'map' }, gives: 'render', create: (api) => { const store = combineStores(api.store) const routes = combineRoutes(api.routes) // yada yada return app } }

which could be abstracted away so the user only has to say whether it is a service module or a store module, then combine the modules and start your desired entry point.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/ahdinosaur/inux/issues/8#issuecomment-267858909, or mute the thread https://github.com/notifications/unsubscribe-auth/ACitni1o_YB41p_IKnlCZwL5uYhpzqQDks5rJdDEgaJpZM4KOaN2 .

ahdinosaur commented 7 years ago

as a follow-up to https://github.com/ahdinosaur/vas/pull/20, here's more on a possible approach to the next inu using depject.

example usage:

const { Store, Page, combine, pull, html } = require('inux')

const data = {
  gives: 'data',
  create: () => () => ({
    1: 'human',
    2: 'computer',
    3: 'JavaScript'
  })
}

const Id = {
  gives: 'Id',
  create: () => () => {
    return Math.random().toString(36).substring(2)
  }
}

const things = Store({
  name: 'things',
  needs: {
    data: 'first',
    Id: 'first',
    things: {
      create: 'first'
    }
  }
  state: (api) => ({ model: api.data }),
  actions: (api) => ({
    create: (model, thing) => ({
      const id = api.Id()
      return { model: { ...model, [id]: thing } }
    }),
    // or
    // set: {
    //   type: Symbol('set'),
    //   create: payload => payload,
    //   scope: ['things'],
    //   update: (state, thing) => ({})
    // }
  },
  effects: (api) => ({
    fetch: (payload, sources) => pull.values([
        api.things.create('rainbow')
    ]),
    // or
    // fetch: {
    //   type: Symbol('fetch'),
    //   create: payload => payload,
    //   run: (payload, sources) => {}
    // }
  })
})

const showPage = Page({
  route: (api) => '/things/:id',
  view: (api) => (model, dispatch) => {
    return html`<div></div>`
  }
})

const { views } = combine({ things, showThing, Id, data })
const main = document.querySelector('.main')
pull(views(), pull.drain(view => html.update(main, view)))

questions:

how does it work?

Store converts a inux store like above into a depject module like:

{
  needs: {
    data: 'first',
    Id: 'first',
    things: {
      create: 'first'
    }
  },
  gives: {
    things: {
      create: true,
      fetch: true
    }
    inux: {
      init: true,
      update: true,
      run: true
    }
  },
  create: Function,
}

where init, update, run are used with depject's map. to combine into a single inu app.

and similarly for Page giving route module which points to a corresponding view.

ahdinosaur commented 7 years ago

playing around with individual action modules:

module.exports = inux.Action({
  needs: {
    Id: 'first'
  },
  create: (api) => ({
    scope: ['cats'],
    update: (cats, newCat) => {
      const id = api.Id()
      return { model: { ...cats, [id]: newCat } }
    })
})

object scope idea (for when your update function needs to access outside the default scope):

module.exports = inux.Action({
  needs: {
    Id: 'first',
    users: {
      getters: {
        getCurrentUser: true
      }
    }
  },
  create: (api) => ({
    scope: {
      users: true,
      cats: true
    },
    update: ({ users, cats }, newCat) => {
      const currentUser = api.users.getters.getCurrentUser({ users })
      const id = api.Id()
      return {
        model: {
          cats: { ...cats, [id]: newCat } }
        }
    })
})

is there a way to make needing getters handle this encapsulation?

module.exports = inux.Action({
  needs: {
    Id: 'first',
    users: {
      getters: {
        getCurrentUser: true
      }
    }
  },
  create: (api) => ({
    scope: ['cats'],
    update: (cats, newCat) => {
      const currentUser = api.users.getters.getCurrentUser()
      const id = api.Id()
      return { model: { ...cats, [id]: newCat } }
    })
})