nimm-sync v2.0.5
NIMM-SYNC
App store for react (much like Redux) but based on hooks
npm install --save nimm-sync
How it works
You create channel definitions, then compile them into a dictionary. Then you connect your app to the dictionary. The connection gives you the hooks you need to interract with the channel definitions.
1. Create channel definitions
const myStore={
users:[],
images:[],
currentUser:null,
}
const myUserChannel = {
key: 'user',
get: username=>myStore.users.find(v=>v.username===username)
}
const myCurrentUserChannel={
key: 'current-user',
get:()=>myStore.currentUser,
set:v => (myStore.currentUser=v);
}
const myImagesChannel = {
key: 'images',
get: username=>myStore.images.filter(v=>v.username===username)
}
const myImageChannel={
key: 'image',
get: id=>myStore.images.find(v=>v.id===id)
}
Channel definitions are simply objects. Each object must have a key
and a get
defined. Key must be destinct between all channels. A getter will be used to extract your value from the store. Notice that nimm-sync does not abstract the store; it simply operates on what every source yous supply. Channel definitions may have other operations defined, infact there are many other default operations such as update, add, and remove. Default behavior for these operations may be redefined and new operations may be added... but more on that later.
2. Compile channel definitions into a single channel dictionary.
const NimmSync = require('nimm-sync');
const myDictionary = NimmSync.create([myUserChannel, myImagesChannel, myImageChannel, myCurrentUserChannel]);
A dictionary is a single gateway which manages event dispatching based on requested operations. If I update current user, then the dictionary will return updated user to the same stream and it will dispatch a bunch of events describing the operation.
3. Connect app environment to a dictionary.
const myConnection = NimmSync.connect(myDictionary)
// or
const {useMessageStream, useStream, useOpenStream} = NimmSync.connect(myDictionary);
The whole point of the connection object is to give you the hooks you want to interract w/ the dictionary. There are 3 hooks which we are interested in. Everything else in myConnection object is of little importance to our app.
Before we look at the hooks lets say more about the connection object. There can be more than a single kind of a connection object and each represents a unique environment. For the most part, your store will be in the same system as your app. If that's not the case, unique connection object can bridge the gap between environments. Currently there's two kinds of connection objects. There's the common connection for a store on the same environment as the app. And there's the SocketIoConnection for a store running on the server being consumed by a client over socket-io. Other connections may be written, each particular to a protocot. So back to hooks.
The three hooks let you open a stream to communicate with a particular channel.
useOpenStream(keyName, at)
-- keyname: name of the channel -- at (optional): param passed to a getter
-- returns: array, 1st member is the object returned from the getter, 2nd object is the operations list for this stream
const [user, operations] = useOpenStream('user', 'fooFoomer@gmail.com')
This hook creates a stream, opens it, and fetches the user object from your store. At first, user will be null (before inital fetch). If you pass a new 'key' or 'at' to a hook, the old stream will close and a new one will open.
useMessageStream(keyName, at)
-- keyname: name of the channel -- at (optional): param passed to a getter
Message stream will not fetch the object but will keep the stream open for communication thus it will return only the operations object.
const operations = useOpenStream('user', 'bob');
useStream(keyName, at)
-- keyname: name of the channel -- at (optional): param passed to a getter
In most cases you will need either an open stream or a messageStream. Both, above mentioned hooks run on 'useStream' architecture. Use this hook only if you need a stream wich is not yet open.
Operations
Here's a list of operations foun on operations object. Keep in mind you can redefine all default operations on channel definition. You can also create new operations which can be accessed using generic 'send' operation.
send('operation', ...args)
-- operations (string): name of operation defined on a channel -- ...args (any): arguments which will be called with the operation defined on the channel.
Call on operation defined on the channel. Most of the operations you woudl need are already on the opeations object exposed by the hook, however should you defined custom operations on the channel you will need to use 'send' to access that operation.
// in channel definition
const myUser={
key:'user',
get:x=>store.users.find(user=>user.id===x),
saveUser: userData => myUserService.save(userData)
}
// in component
const [user, {send}] = useOpenStream('user', 'bob');
const onSaveClicked = ()=> {
send('saveUser', {id: user.id, clicked: true})
}
add(...args)
-- ...args (any): calls add operation on the channel
By default add is a proxy to push. It assumes push is a method on an object retrieved by a getter. As with all default operations you can override how add functions on a channel.
// in channel definition
const myUsers={
key:'users',
get:()=> store.users
add:(username, user)=>(store.users[username]=user)
}
// in component
const [users, {add}] = useOpenStream('users');
const onAddClicked = (user)=> {
add(user.username, user)
}
remove(index)
-- index (number): index at to be removed
By default remove is a proxy to splice. It assumes splice is a method on an object retured by the getter. As with all default operations you can override how remove functions on a channel.
update(src)
-- src (object): object to be merged into destination
By default update merges the src object into destination object which is returned by the getter. It assumes the object returned by the getter is an object. As will all default operations you can redefined how update works on the channel.
updateMember(atMember, src)
-- atMember (number | string): index or prop on a destination object -- src (object): object to be merged into destination
By default updateMember updates a member on the destination object retrieved by the getter. It assumes the object returned by the getter is an array or an object and that a destination object atMember is an object. You can redefined how updateMember works on channel definition.
// in definition
const store={
users: [
{name:'bob', seen:true},
{name:'dave', seen:false}
]
}
const myUsers={
key:'users',
get:()=>store.users,
}
// in component
const [users, {updateMember}] = useOpenStream('users')
const onUpdateBobClicked = ()=> {
updateMember(1, {seen: true})
}
setProp(prop, val)
-- prop (string): prop to be set -- val (any): value to be set on prop
Set prop on an object retrieved by getter.
removeProp(prop)
-- prop (string): prop to be deleted on the destination object
Delete prop on an object retrieved by getter.
get()
Force a getter to run, reloading the response returned from the open stream. This is useful for creating relationships between channels.
//in definition
const users={
key:'users',
get:()=>store.users,
set:users=>(store.users=users)
}
const userNames={
key:'user-names',
get:()=>store.users.map(v=>v.username)
}
//in component
const [user, {set}]=useOpenStream('users')
const setMyUsers = (users)=> {
set(users)
}
//in another component
const [usernames, {get}]=useOpenStream('user-names');
const {watch:usersWatch} = useMessageStream('users');
usersWatch(()=>get());
In above examples every time someone does any operation on users, re-evaluate usernames
set(...args)
-- args (any): arguments massed to set operation defined on a channel.
Calls set operation on a channel. There is no default set operation defined for a channel, so one must be defined for set to work.
//in definitions
const mySelectedImage = {
key:'selected-image',
get:()=>store.selectedImage,
set:v=>(store.selectedImage=v)
}
//in component
const [selectedImage, {set}] = useOpenStream('selected-image');
const setSelectedImage = (selectedImage)=> {
set(selectedImage);
}
open()
Opens a stream. By default useOpenStream
hook will call this internally, useStream
will create a stream not yet opened.
close()
Close an opened stream. By default useOpenStream
hool will close a stream when component is unmounted or when a stream scope has changed, useStream
will not close the stream automatically since it does not open streams automatically.
request(operation, ...args)
-- operation (string): operation to call. This operation may be defined on a channel or handled in anther component. Operation handler on a channel definition must return a non-undefined response to be considered. Operation handlers defined in other components may return undefined. -- args (any): Arguments to call with the operation.
returns: Promise which resolves with a response data.
Request object acts much like 'send' but waits to recieve a response. The process handling the request can be defined on a channel definition or on another component using 'on' or 'watch' operation. Keep in mind, given the order of data processed, channel definition will allways have the first chance to handle the request. Which ever handler returns a response first will be processed.
//in definition
const userChannel={
key:'user',
get:x=>store.users.find(v=>v.id===x),
save:(user, updates)=>myService.save(user, updates) //returns promise
}
//in component
const [user, {request, update}] = useOpenStream('user', 'bob');
const [savingUser, setSavingUser] = useState(false)
const saveMyUser = async () => {
setSavingUser(true);
const newUser = await request('save', user.id, {lastUpdated: +new Date()});
setSavingUser(false);
update(newUser);
}
Operation need not be defined on a channel for it to handle requests. But if defined on a channel it will have priority in sending a response and they must return a non-undefined response.
on(operation, fn)
-- operation (string) (optional): watch the stream on a specific operation -- fn (function): handler to process operation
Create an event handler which will watch for an event on a specific channel. The handler is given a RequestObject. If a handler returns a promise, system will respond with a resolved value.
//in component
const [user, {request}] = useOpenStream('user', 'bob')
const saveMyBob = async()=> {
const newBob = await request('save', {isMarried: true});
}
//in some other component
const {on} = useMessageStream('user', 'bob');
const [numberOfTimesSaved, setNumberOfTimesSaved] = useState(false)
on('save', requestObject=> {
//requestObject[0] -> {isMarried:true}
//requestObject.key -> 'user'
//requestObject.at -> 'bob'
//requestObject.operation -> 'save'
//requestObject.args -> [{isMarried:true}]
setNumberOfTimesSaved(v=>v++)
})
in the above example on handler will execute only on save operation and only on bob. If useMessageStream was openened w/ out specifying 'at' token, all save operations on user would be handled. If the handler was defined w/ out specifying operation, 'on' would handle all user operations. Given the nature of message flow don't do specific operations w/ in generic event handlers since they will generate infinity loops
// DONT DO
//in definition
const images={
key:'images',
get:()=>store.images,
set:newImages=>(store.newImages=newImages),
'image-ids': ()=>store.images.map(v=>v.id)
}
const [images, {on, request}] = useOpenStream('images')
const [imageIds, setImageIds]=useState(null)
on(request==> {
request('image-ids').then(v=>setImageIds(v))
})
Lets say in above example you want just the image ids when images changed. So you define an operation on a channel, so anytime images are set you make your component request the image-ids operation on the same channel. So when something runs 'set' on a channel, the component will handle the operation and request the 'image-ids' operation. However since 'image-ids' operation is also an operation on the same channel 'on' will also request the same operation. Create specific event handlers or ensure you're not operating on the same channel w/ in it's listener (on or watch)
watch(operation, fn)
-- operation (string) (optional): watch the stream on a specific operation -- fn (function): handler to process operation
Same as 'on' but will not communicate the arguments. In a system where store is in a different context than the app connecting to it, it may be expancive to move the arguments. Watching for an event will allow you to request specific operations to filter or trim the data before hand.
//in definitions
const images={
key:'images',
get:()=>store.images
set:images=>(store.images=images);
}
const imageIds={
key:'image-ids',
get:()=>store.images.map(v=>v.id)
}
//in component which sets images from db
const {set}=useMessageStream('images')
getDbImages().then(set)
//in component which only needs ids
const [imageIds, {get}]=useOpenStream('image-ids');
cosnt {watch:imagesWatch} = useMessageStream('images');
imagesWatch(get);
In above example the store is on the server. We're running nimm-react on the server to initialize images from the db. Client app connecting over socket-io does not want to see 7000 image objects move accross the socket, so we create a relationship between 'images' and 'image-ids' channel. Anytime an operation is run on images, image-ids will be sent to the client component.
Testing
Modules are hard to test. So NimmReact features a provider and a hoc to wire up components. The Provider will take a connection object and hoc will expose the 3 hooks as props.
//in setup.js
import NimmSync from 'nimm-sync';
export const store={
users:['bob']
}
const users={
key:'users',
get:()=>store.users
}
const def=NimmSync.create([users])
export const con=NimmSync.connect(def);
//in MyComp.jsx
import {hoc} from 'nimm-sync'
export const MyComp=({someProp, useOpenStream, useStream, useMessageStream})=> {
const [users] = useOpenStream('users');
return <div>{users.join(',')}</div>
}
export default hoc(MyComp);
//in App.jsx
import {con} from './setup.js'
import {Provider} from 'nimm-sync';
import HOC_COMP from './MyComp'
const App = ()=> {
return <Provider value={con}><HOC_COMP></Provider>;
}
If you need to test MyComp.jsx
import {MyComp} from './MyComp'
import {render} from '@testing-library/react'
import NimmSync, {Provider, hoc} from 'nimm-sync';
describe('test', ()=> {
cosnt mount=(store={}, p={}) => {
const props={
...p
}
const users={
key:'users',
get:()=>store.users
}
const dict=NimmSync.create([users])
const con=NimmSync.connect(dict);
return render(<Provider value={con}>{hoc(MyComp)}</Provider>)
}
it('works', ()=> {
const firstUser='frodo'
const store={
users:[firstUser]
}
const {container} = mount(store)
expect(container.find('div').innerHTML).toBe(firstUser)
})
})