Open ahdinosaur opened 8 years ago
do
functioni'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.
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: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
.
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
.
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.
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.
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.
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.
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 .
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:
reselect
getters?Store
and Page
be functions called within plain depject's create
?scope
and route views receiving the whole model, how can we express the data dependencies that each needs?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.
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 } }
})
})
here's an issue to pitch breaking changes for
inux
: